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 { /** The current state, as a read-only Angular signal. */ readonly model: Signal; /** 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 `runIfSubmitting()` call it replaces did. */ export type StoreEffects = Model extends { tag: string } ? { [K in Model['tag']]?: ( state: Extract, store: Store, ) => unknown; } : never; function hasTag(value: unknown): value is { tag: unknown } { return typeof value === 'object' && value !== null && 'tag' in value; } export function createStore( init: Model, update: (model: Model, msg: Msg) => Model, effects?: StoreEffects, ): Store { const model = signal(init); const store: Store = { 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 | undefined)?.[String(next.tag)] as ((state: Model, store: Store) => unknown) | undefined; handler?.(next, store); }, }; return store; }