Follow-up to #477 / PR #702, which fixed Sales only. Three findings were raised by review there, deferred deliberately, and recorded on the closed issue rather than filed. This is them.
The defect, restated
An in-flight write whose dialog was dismissed runs its success side effects unconditionally when it lands: it resets fields the user has since typed into and force-closes the dialog that replaced it, discarding their input. useDialogErrors (#479) stops an abandoned attempt reporting a failure; nothing covered the success direction, which is worse because it destroys work rather than withholding a message.
#702 fixed this on Sales with useDialogSession — a per-scope monotonic generation, claimed before the first await, checked before any write to state the on-screen session owns.
What is still broken
1. The same hijack on the other #479 screens. Customers, Daily Entry, Flocks, Grades and Products all have equivalent create paths. CustomersPage.tsx:127 is representative: submit New customer, cancel, reopen and type, then let the old success land — it resets the fields and closes the replacement form.
2. Non-dialog run callers are unprotected. Sales gates only dialog scopes; everything else is permanently "current" by design. The same stale-success shape exists on panel actions — start Add line, close the panel, let the request finish, and the panel reopens. Update / remove / confirm / void have the same shape.
3. useDialogSession fails open on a mistyped scope. begin("create") with claim("create-order") never invalidates. No current path makes that mistake, and it is a deliberate default — a screen that has not adopted begin behaves exactly as it did before the hook existed, rather than silently gating every success off — but it is a sharp edge once the hook is used widely.
Why a shared hook, not five more copies
Measured on main:
- 19
run("scope", …) action call sites across 13 screens.
- 11 screens hand-write the same
beginAttempt → try → report wiring inline.
- Only
SalesPage wrapped it in a run() helper — and that wrapper is where the correct gating now lives.
So the wiring is re-derived per screen, and each re-derivation is a chance to get it wrong. That is not hypothetical: this exact class has now cost four review rounds (#474 → #477 → #479 → #702).
Promote SalesPage's run wrapper into a shared hook that performs beginAttempt, claims the session, wraps the try/catch, calls report, and hands the action a current() predicate. Each action then loses ~6 lines of plumbing and gets the gate by default.
What this does NOT fix — read before scoping
A shared hook makes the check available and consistent. It does not make the bug impossible. It cannot decide what a superseded success must still do, and that judgement is where two of the five defects found in #702 actually were:
- key rotation /
clearKey must run when superseded — skip it and the next write replays the abandoned one under a spent idempotency key;
- a money confirmation must run — the payment happened, and silence invites paying twice;
- form resets and the dialog close must not run.
Every migrated call site needs that question answered individually. A <FormDialog> component owning open/close/session/errors would close more of the gap by construction, but it rewrites every dialog's markup — noted as an alternative, not proposed here.
Suggested scope — not one PR
Screen inventory, measured on main — modal count and errors.beginAttempt count per route file. The original step 2 named five screens — the #479 list copied forward, not a measurement. Five more screens have dialogs and gated actions and carry the identical bug. Corrected here.
| Screen |
Dialogs |
Gated actions |
Step |
Wiring today |
| Sales |
4 |
1 |
1 |
already correct (#702) |
| Customers |
4 |
2 |
2 |
inline, hand-written ×2 |
| Daily Entry |
2 |
1 |
2 |
inline |
| Flocks |
6 |
1 |
2 |
run(scope, errorScope, …) wrapper |
| Grades |
4 |
1 |
2 |
run(scope, dialogScope, …) wrapper |
| Products |
6 |
3 |
2 |
mixed — one wrapper, two inline |
| Users |
14 |
8 |
2b |
inline ×8 |
| Inventory |
8 |
3 |
2b |
mixed |
| Expenses |
4 |
2 |
2b |
mixed |
| History |
2 |
2 |
2b |
inline |
| Stock |
2 |
1 |
2b |
inline |
| Feed |
0 |
0 |
— |
not affected — inline form, no dialog session to supersede; runWrite already covers the list |
| Water |
0 |
0 |
— |
same |
- Extract the shared hook and migrate Sales onto it. Behaviour-neutral; proves the shape against the one screen already known correct.
- Migrate Customers, Daily Entry, Flocks, Grades, Products — 8 actions, answering the superseded-safe question per action. Flocks/Grades/Products already have the wrapper, so most of the diff is Customers + Daily Entry.
2b. Migrate Users, Inventory, Expenses, History, Stock — 16 actions. Users takes its own PR: 8 actions including set-password, change-role, change-email and disable/enable, where a wrong gate decision is expensive to detect.
- Handle the non-dialog / panel callers separately; a panel is not a dialog session and the right semantics differ.
- Decide on finding 3 (fail-open scope) once the hook has more than one consumer.
19 call sites is a diff large enough to hide a defect, which is the main argument for splitting it.
One ordering that must survive the migration
Flocks, Grades and Products already run refresh() before clearKey(scope), deliberately: if the refresh throws, the key survives and a retry replays the idempotent write instead of duplicating it. Both lines are facts about the world, so both must still run when superseded, and in that order. Gating either one is #702's P1 repeated.
Verify
Per migrated screen: a test that submits, dismisses, reopens, then lands the original success — asserting the replacement session's input survives and its dialog stays open. Mutation-check each gate (removing it must redden that screen's named test). Pin anything that must still run when superseded — key rotation especially — with its own test, because that is the direction that silently loses money or orders.
Follow-up to #477 / PR #702, which fixed Sales only. Three findings were raised by review there, deferred deliberately, and recorded on the closed issue rather than filed. This is them.
The defect, restated
An in-flight write whose dialog was dismissed runs its success side effects unconditionally when it lands: it resets fields the user has since typed into and force-closes the dialog that replaced it, discarding their input.
useDialogErrors(#479) stops an abandoned attempt reporting a failure; nothing covered the success direction, which is worse because it destroys work rather than withholding a message.#702 fixed this on Sales with
useDialogSession— a per-scope monotonic generation, claimed before the firstawait, checked before any write to state the on-screen session owns.What is still broken
1. The same hijack on the other #479 screens. Customers, Daily Entry, Flocks, Grades and Products all have equivalent create paths.
CustomersPage.tsx:127is representative: submit New customer, cancel, reopen and type, then let the old success land — it resets the fields and closes the replacement form.2. Non-dialog
runcallers are unprotected. Sales gates only dialog scopes; everything else is permanently "current" by design. The same stale-success shape exists on panel actions — start Add line, close the panel, let the request finish, and the panel reopens. Update / remove / confirm / void have the same shape.3.
useDialogSessionfails open on a mistyped scope.begin("create")withclaim("create-order")never invalidates. No current path makes that mistake, and it is a deliberate default — a screen that has not adoptedbeginbehaves exactly as it did before the hook existed, rather than silently gating every success off — but it is a sharp edge once the hook is used widely.Why a shared hook, not five more copies
Measured on
main:run("scope", …)action call sites across 13 screens.beginAttempt→try→reportwiring inline.SalesPagewrapped it in arun()helper — and that wrapper is where the correct gating now lives.So the wiring is re-derived per screen, and each re-derivation is a chance to get it wrong. That is not hypothetical: this exact class has now cost four review rounds (#474 → #477 → #479 → #702).
Promote SalesPage's
runwrapper into a shared hook that performsbeginAttempt, claims the session, wraps thetry/catch, callsreport, and hands the action acurrent()predicate. Each action then loses ~6 lines of plumbing and gets the gate by default.What this does NOT fix — read before scoping
A shared hook makes the check available and consistent. It does not make the bug impossible. It cannot decide what a superseded success must still do, and that judgement is where two of the five defects found in #702 actually were:
clearKeymust run when superseded — skip it and the next write replays the abandoned one under a spent idempotency key;Every migrated call site needs that question answered individually. A
<FormDialog>component owning open/close/session/errors would close more of the gap by construction, but it rewrites every dialog's markup — noted as an alternative, not proposed here.Suggested scope — not one PR
Screen inventory, measured on
main— modal count anderrors.beginAttemptcount per route file. The original step 2 named five screens — the #479 list copied forward, not a measurement. Five more screens have dialogs and gated actions and carry the identical bug. Corrected here.run(scope, errorScope, …)wrapperrun(scope, dialogScope, …)wrapperrunWritealready covers the list2b. Migrate Users, Inventory, Expenses, History, Stock — 16 actions. Users takes its own PR: 8 actions including set-password, change-role, change-email and disable/enable, where a wrong gate decision is expensive to detect.
19 call sites is a diff large enough to hide a defect, which is the main argument for splitting it.
One ordering that must survive the migration
Flocks, Grades and Products already run
refresh()beforeclearKey(scope), deliberately: if the refresh throws, the key survives and a retry replays the idempotent write instead of duplicating it. Both lines are facts about the world, so both must still run when superseded, and in that order. Gating either one is #702's P1 repeated.Verify
Per migrated screen: a test that submits, dismisses, reopens, then lands the original success — asserting the replacement session's input survives and its dialog stays open. Mutation-check each gate (removing it must redden that screen's named test). Pin anything that must still run when superseded — key rotation especially — with its own test, because that is the direction that silently loses money or orders.