feat: add an optional effect map to createStore (RD-05)

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>
This commit is contained in:
eho
2026-09-04 16:34:28 +02:00
co-authored by Claude Sonnet 5
parent 0697afb737
commit a71887a843
5 changed files with 311 additions and 8 deletions
+51 -6
View File
@@ -17,17 +17,62 @@ export interface Store<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 `runIfSubmitting()` call 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);
return {
const store: Store<Model, Msg> = {
model: model.asReadonly(),
// 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).
dispatch: (msg) => model.update((m) => update(m, msg)),
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;
}