createStore now takes a third, optional StoreEffects map. Each key is a Model tag. The store runs that tag's handler after update() returns, and only when the store enters the tag: the previous tag differs from the new tag, and the message is not Seed (the mount/restore message in every machine that has one). This closes the gap where a component had to call dispatch(msg) and then a private runIfSubmitting() by hand, or state got silently stuck. No call site changes here. RD-06 and RD-08 migrate the 5 components that duplicate that pattern today. The effect map is a conditional type, not a generic constraint, so a tagless Model (store.spec.ts's plain number store) still resolves it to never and needs no third argument. Both tag checks use a typeof/in guard for the same reason. Regenerated libs/shared/docs/behaviour-spec.mdx for the 5 new spec titles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.3 KiB
RD-05 — createStore gains an effect map
Status: done Source: PLAN.md 1a
Why
runIfSubmitting is not shared. It is a private async method copy-pasted into 5 components
under 2 names, and it must be called by hand immediately after dispatch:
this.dispatch({ tag: 'Submit' }); // the reducer decides
this.runIfSubmitting(); // then re-read state() and re-check the tag it hoped for
Forgetting the second line fails silently. This ticket makes that impossible by moving the effect into the one sanctioned wiring idiom, so entering a state runs its effect by construction.
This ticket is behaviour-neutral: it changes no call site. RD-06 and RD-08 migrate them. Landing the mechanism alone keeps the risk isolated to one reviewable commit.
Read first
libs/shared/src/application/store.ts— all 33 lines. The comment at lines 27-30 is the specification for the invocation order below.libs/shared/src/application/store.spec.ts— both existing tests. Neither may be deleted or weakened.PLAN.md1a, including the two rejected alternatives.libs/shared/src/application/submit.ts—runResult,runSubmit,SUBMIT_FAILED.
Decisions (pre-made, don't relitigate)
-
The API. In
libs/shared/src/application/store.ts:export type StoreEffects<Model, Msg> = Model extends { tag: string } ? { [K in Model['tag']]?: ( state: Extract<Model, { tag: K }>, store: Store<Model, Msg>, ) => unknown; } : never; export function createStore<Model, Msg>( init: Model, update: (model: Model, msg: Msg) => Model, effects?: StoreEffects<Model, Msg>, ): Store<Model, Msg>; -
A conditional type, NOT a
Model extends { tag: string }constraint. This is not stylistic.store.spec.ts:8callscreateStore(0, (n: number, m: number) => n + m), whereModel = numberand has notag. A constraint breaks that existing spec. With the conditional,Model = numberresolvesStoreEffectstonever, so passing effects there is a compile error while omitting them stays legal. -
Keys are
Model['tag'], so a renamed or misspelled state tag is a compile error. This buys the typo half of exhaustiveness. The completeness half (a full Elm[state, Cmd]) is knowingly not bought — see PLAN.md 1a for why. -
The narrowed state is argument one. This is what deletes the
const s = this.state(); if (s.tag !== 'Submitting') return;preamble at all 8 call sites in RD-06/RD-08. The body cannot run in the wrong state, so it cannot re-guess it. -
The store is argument two. The effect needs
dispatch, butcreateStore(...)runs in a field initializer beforethis.storeis assigned. Build the store object, then close over it, so the effect is independent of field-declaration order. -
The trigger rule. Run
effects[next.tag]when both hold:prev.tag !== next.tag— the store entered the tag. ASubmitthat fails validation isEditing → Editing: no fire. A secondSubmitwhileSubmittingis a reducer no-op: no fire, so double-submit protection falls out of the rule.RetryisFailed → Submitting: fires, soonRetryneeds no special case anywhere.- the msg tag is not
Seed. Without this, the five components that mount aSubmittingstate in Storybook viaSeedfire real network calls (see Risks), anddraftSync.onResumere-submits a resumed draft. Document it onStoreEffectsas the convention it already is:Seedis the mount/restore message in all 7 machines that have one.
-
Invocation order. Capture
prevandnextinside themodel.update(...)callback into locals, and invoke the effect afterupdatereturns. Do not readmodel()insidedispatch, and do not invoke the effect inside the updater (a signal write nested in an updater).store.ts:27-30explains why: a tracked read there makes an effect depend on its own write and livelocks the main thread. It already crashed the upload wizards once. -
The tag check must be safe on a non-object
Msg. Same reason as decision 2 —store.spec.ts:8dispatches plain numbers. Guard withtypeof msg === 'object' && msg !== null && 'tag' in msg, never a baremsg.tag. The same applies to readingprev.tag/next.tagwhenModelis not an object. -
dispatchstaysvoid-returning. The effect's promise is floated, exactly asthis.runIfSubmitting();is floated today. Every effect body ends in aResultfromrunSubmit/runResult, so it cannot throw — state that as the effect contract in the doc comment rather than adding a try/catch. -
No call site changes in this ticket. Do not migrate any component. Do not touch the 5 components or any
*.machine.ts.
Files
libs/shared/src/application/store.ts— the type, the third parameter, the trigger rule, and a doc comment covering the effect contract and theSeedconvention.libs/shared/src/application/store.spec.ts— 5 new cases, both existing cases untouched.
Steps
- Add
StoreEffects<Model, Msg>per decision 1. - Add the optional third parameter and implement the trigger rule per decisions 6-8.
- Extend the doc comment: what the effect slot is for, the "never throws, returns a
Result" contract, and whySeedis exempt. - Add the 5 spec cases from Acceptance below.
- Run
npm run gen:behaviour-spec— newit()titles mean the drift check fails without it (see Risks). - Update this ticket's
Status:todoneand the README's RD-05 row todone. - Commit all of it together.
Acceptance criteria
Five new plain-function spec cases, no TestBed:
- fires the effect when the store enters the tag
- does not fire when the tag is unchanged
- does not fire for a Seed message
- a dispatch from inside the effect lands
- the narrowed state is passed to the effect
Both existing cases still present and passing — in particular
dispatch from inside an effect does not self-loop, which is the regression guard for
decision 7.
npm test # exits 0
npm run ci # exits 0
Type-level proof that decision 2 holds, i.e. the old call shape still compiles:
npm run typecheck # exits 0 — store.spec.ts:8's createStore(0, ...) must still type-check
Verification
npm run ci. This ticket touches no story, no .mdx, and no component, so --full is not
required.
Out of scope
- Migrating any call site. RD-06 (the 2 single-step forms, which is also a bug fix) and RD-08 (the 3 wizards).
- Adding a
Primarymessage to any machine. That is RD-07. - The full Elm
reduce -> [state, Cmd]refactor. Rejected in PLAN.md 1a, with reasons; recorded there as the documented upgrade path if effects ever need asserting inside a domain spec. upload.machine.ts'stype:discriminant. That is optional RD-35.
Risks
- The Storybook trap. All 5 components mount their
Submitting/Indienenstate viaSeedin stories that use a realprovideHttpClient()with no request mocking (besluit-form.stories.ts:33,herregistratie-wizard.stories.ts:70,intake-wizard.stories.ts:38,change-request-form.stories.ts:34,registratie-wizard.stories.ts:88). TheSeedexemption is what stops them firing real calls and reddeningstorybook-a11y. Nothing migrates in this ticket, so the trap does not fire yet — but the exemption must be implemented and documented here, because RD-06 is where it would otherwise bite. - Two dispatch sites live inside Angular
effect()s —intake-wizard.component.ts:363(SetPolicy) andregistratie-wizard.component.ts(PrefillAdres). Both land on an unchanged tag, so nothing fires, and both are alreadyuntracked. Never key an effect on an editing tag — that is the livelock. behaviour-spec.mdxdrift.scripts/ci-local.shregenerateslibs/shared/docs/behaviour-spec.mdxfrom a path-sorted walk of spec titles and fails on drift. Newit()titles must be accompanied bynpm run gen:behaviour-specin the same commit.snippets.generated.tsdrift. Verified:store.tscarries no// #region showcase:marker, so this ticket cannot cause snippet drift. (remote-data.ts:30hasshowcase:fold, which RD-11 and RD-17 must respect — not this ticket.)