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:
@@ -29,4 +29,75 @@ describe('createStore', () => {
|
||||
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
|
||||
expect(store.model()).toBe(1);
|
||||
});
|
||||
|
||||
type ToggleModel = { tag: 'Off' } | { tag: 'On' };
|
||||
type ToggleMsg = { tag: 'Seed' } | { tag: 'Flip' } | { tag: 'Stay' };
|
||||
|
||||
function reduceToggle(model: ToggleModel, msg: ToggleMsg): ToggleModel {
|
||||
switch (msg.tag) {
|
||||
case 'Flip':
|
||||
return model.tag === 'Off' ? { tag: 'On' } : { tag: 'Off' };
|
||||
case 'Seed':
|
||||
return { tag: 'On' }; // mounts straight into 'On', e.g. restoring a draft
|
||||
case 'Stay':
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
it('fires the effect when the store enters the tag', () => {
|
||||
let fired = false;
|
||||
const store = createStore<ToggleModel, ToggleMsg>({ tag: 'Off' }, reduceToggle, {
|
||||
On: () => (fired = true),
|
||||
});
|
||||
|
||||
store.dispatch({ tag: 'Flip' });
|
||||
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it('does not fire when the tag is unchanged', () => {
|
||||
let fired = false;
|
||||
const store = createStore<ToggleModel, ToggleMsg>({ tag: 'Off' }, reduceToggle, {
|
||||
Off: () => (fired = true),
|
||||
});
|
||||
|
||||
store.dispatch({ tag: 'Stay' }); // Off -> Off, no tag change
|
||||
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire for a Seed message', () => {
|
||||
let fired = false;
|
||||
// Off -> On is a real tag change, but Seed is the mount/restore message
|
||||
// (e.g. a Storybook story or a resumed draft) and must stay exempt.
|
||||
const store = createStore<ToggleModel, ToggleMsg>({ tag: 'Off' }, reduceToggle, {
|
||||
On: () => (fired = true),
|
||||
});
|
||||
|
||||
store.dispatch({ tag: 'Seed' }); // Off -> On
|
||||
|
||||
expect(store.model()).toEqual({ tag: 'On' });
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it('a dispatch from inside the effect lands', () => {
|
||||
const store = createStore<ToggleModel, ToggleMsg>({ tag: 'Off' }, reduceToggle, {
|
||||
On: (_s, s) => s.dispatch({ tag: 'Flip' }), // On -> Off, from inside the effect
|
||||
});
|
||||
|
||||
store.dispatch({ tag: 'Flip' }); // Off -> On, fires the effect above
|
||||
|
||||
expect(store.model()).toEqual({ tag: 'Off' });
|
||||
});
|
||||
|
||||
it('the narrowed state is passed to the effect', () => {
|
||||
let seen: ToggleModel | undefined;
|
||||
const store = createStore<ToggleModel, ToggleMsg>({ tag: 'Off' }, reduceToggle, {
|
||||
On: (state) => (seen = state),
|
||||
});
|
||||
|
||||
store.dispatch({ tag: 'Flip' });
|
||||
|
||||
expect(seen).toEqual({ tag: 'On' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user