Skip to content

SPA: pending state on every mutating save — spinner, live announcement, double-submit guard (#236) - #242

Merged
mforce merged 5 commits into
mainfrom
feat/236-save-pending
Jul 28, 2026
Merged

mforce merged 5 commits into
mainfrom
feat/236-save-pending

Conversation

@mforce

@mforce mforce commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #236 — pending/busy state on every mutating save. Found while testing #162: a save waiting on the account-row lock looked exactly like a dead button.

Process

Design-first, per request: the design doc (docs/superpowers/specs/2026-07-27-save-pending-design.md) went through three reviewers (codex, pi, architect agent) plus a codex re-pass before any code; implementation ran as four coding subagents (shared mechanism first, then three parallel screen sets on disjoint files); a 4-reviewer code round follows on this PR.

What

Shared mechanism (web/src/components/)

  • usePendingAction — busy + isPending(scope) + run(scope, action). Double-submit guard on a synchronous ref (state can't stop two same-tick calls — unit-tested with two un-awaited run()s → one invocation). Idempotency keys and refresh-before-rotate deliberately stay in the screens' review-hardened helpers; boolean wrappers map a skipped run (undefined) to false explicitly, pinned by a test.
  • BusyButton — children untouched (dynamic labels like Login's "Signing in…" stay the caller's; exact accessible names pinned), centered aria-hidden spinner, single-layer label dim, always-mounted sibling role="status" region ("Working…", all 3 locales) — sibling because aria-busy defers announcements inside the busy element; always-mounted because a region that mounts pre-populated is unreliably announced.
  • CSS: button:disabled[aria-busy="true"] { opacity: 1 } — the design-review blocker; without it the global :disabled opacity halves the spinner and busy looks exactly like dead.

Migration — all 14 mutating screens, ~55 triggers:

  • Per-row spinners via scoped pending (archive:<id>, composite assign:<user>:<flock>, adjust:<item>:<lot>), whole screen inert via busy, only the clicked verb spins (two-verbs-one-row tested).
  • Confirm-dialog handoff: dialogs settle before I/O (their buttons never own a flight); the originating row control carries the pending state — tested as observable state, not commit-order.
  • Carve-outs: Settings logo file-input (not a button — keeps disabled + its existing status region), LanguageSelector <select> (previously fire-and-forget with no guard, now guarded, no spinner), sign-out (excluded by decision).
  • Two real guard gaps closed: DailyEntryPage create-flock (fully unguarded, double-submittable) and LanguageSelector.
  • Sales' scopeless helper gained scopes; its two guarded reads get no spinner treatment (SPA: pending/busy state on every save — lock waits (#162) are invisible to the user #236 is writes).

Tests

+32 (870 total, all green): hook lifecycle + same-tick guard; BusyButton a11y contract (exact accessible name, status region outside the button); held-promise screen tests — double submit under flight (Customers, DailyEntry), confirm-handoff (Flocks), two-verbs-one-row (Sales), scope isolation + focus-effect survival (Settings), dialog-close-leaves-nothing-busy (Sales, Products, Users), skip-never-closes-dialog (Grades). Existing 838 untouched-green (4 legitimately adjusted, reasons in commits).

Coverage rose on every axis; floors ratcheted up to 88/85/79/74 (standing re-baseline rule).

Docs

Help page: one line (spinning button = save still working; pressing again won't double-record), i18n'd in en/es/tl. GLOSSARY untouched — no new domain term.

mforce added 2 commits July 27, 2026 02:10
Every save that takes seconds — a lock wait behind a currency change
(#162), a cold DB, bad rural connectivity — used to look identical to a
dead button (the only visual was :disabled opacity). Three hand-rolled
busy conventions and one fully unguarded form (daily-entry create-flock)
are replaced by one shared mechanism:

- usePendingAction: busy + isPending(scope) + run(scope, action), with the
  double-submit guard on a synchronous ref (state cannot stop two calls in
  one tick). Idempotency keys and refresh-before-rotate stay in the
  screens' reviewed helpers, now rebased on the hook; boolean wrappers map
  a skipped run to false explicitly.
- BusyButton: children untouched (dynamic labels stay the caller's),
  centered aria-hidden spinner, single-layer label dim, and an
  always-mounted sibling role=status region ('Working…', i18n'd) — sibling
  because aria-busy defers announcements INSIDE the busy element. CSS
  overrides the global :disabled opacity for busy buttons; without that
  the spinner would render at half opacity and the busy state would look
  exactly like the dead button this issue removes.
- All 14 mutating screens migrated (~55 triggers): per-row spinners via
  scoped pending (composite scopes where payload-bound), whole screen
  inert via busy, confirm-dialog handoff (dialog settles before I/O, the
  originating row control carries the pending state), carve-outs for the
  logo file-input and the language select, LanguageSelector's
  fire-and-forget PUT now guarded.
- Design doc (v3) reviewed by three reviewers pre-implementation; the
  review-round blockers (disabled-opacity cancellation, same-tick guard,
  aria-busy deferral, inventory drift) are all encoded above.
- Tests: +32 (hook, BusyButton, and held-promise screen tests: double
  submit under flight, two-verbs-one-row isolation, scope isolation on
  Settings, dialog-close leaves nothing busy, skip-never-closes-dialog).
  Coverage floors ratcheted up (88/85/79/74).
- Help page: one line — a spinning button means the save is still working.

Closes #236
- Row edit/correct openers are non-mutating and no longer share the dialog
  save scope — the opener spun (and announced) alongside the dialog submit
  during updates; openers are plain buttons disabled while busy (codex).
- Inputs whose value is embedded in a pending scope lock with the flight:
  the assign-flock select (cavecrew), the add-category name input and the
  adjustment lot select (codex) — editing them mid-flight re-pointed
  isPending at a scope nobody runs and dropped the live spinner. Audit
  confirmed no other input feeds a visible pending scope.
- Settings logo status announces only the upload; removal is announced by
  the Remove button own live region — the two regions both said Working
  during a removal (codex).
- Sales order-list Open button gains disabled={busy} like every sibling
  read control (reviewer consistency note).
- styles.css: the busy-opacity override now names the base rule it exists
  to beat (pi).
@mforce

mforce commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Review rounds outcome

This PR ran the full requested pipeline: design → 3-reviewer design round → codex re-pass → subagent implementation (4 coding agents) → 4-reviewer code round → fixes.

Design rounds (before any code)

  • v1 reviewed by codex (REDESIGN), pi (APPROVE-WITH-CHANGES), architect agent (APPROVE-WITH-CHANGES) → 16 amendments accepted into v2 (sync ref guard, usePendingAction rename, disabled-opacity CSS blocker, file-input carve-out, aria-busy-deferral live-region placement, honest ~52-trigger inventory, confirm-dialog handoff model, framework-alternatives evaluation).
  • codex re-pass on v2 → v3 (neutral "Working…" text, always-mounted live region, composite scopes decoupled from idempotency-key scopes, matrix corrections, observable-state handoff spec).
  • 6 suggestions rejected with reasons recorded in the design appendix (mounted-ref, aria-disabled swap, console.warn, focus parking, discriminated result, useActionState).

Code round (codex + pi + 2 Claude agents)

  • pi: READY (10 findings, all Low/Info, each self-marked "no fix required" — one comment suggestion applied).
  • Claude reviewer: READY, zero findings ≥80 — verified CSS specificity math, hand-checked every handler↔JSX scope pair, confirmed double-submit tests drive the Enter-key path so the ref (not the disabled attribute) is proven to guard.
  • codex: 3 Important, all accepted → fixed (c7486ba): row edit/correct openers no longer share the dialog-save scope (they spun + announced during saves); scope-defining inputs (add-category name, adjustment lot select) lock with the flight — editing them mid-flight re-pointed isPending at a scope nobody runs and dropped the live spinner; Settings logo status now announces upload only (removal is the Remove button's own live region — the two both said "Working…" during removal).
  • cavecrew: 1 bug accepted → fixed (assign-flock select, same scope-drift class, was the first instance found); 1 style note rejected (busy || undefined is semantically identical and pinned by tests).
  • Also applied: Sales "Open" disabled={busy} consistency (Claude reviewer sub-threshold note).

Final state

  • Web: 871 tests green (838 baseline + 33 new), zero console/act noise.
  • Coverage rose on every axis (88.52 lines / 85.49 stmts / 80.09 funcs / 75.01 branches); floors ratcheted up to 88/85/79/74.
  • tsc -b clean, prod build clean, .NET untouched.

Resolves conflicts between the #236 pending-state rollout and the F182
i18n sweeps (B4/B5 + es/tl catch-up): pending-state structure (BusyButton,
usePendingAction scopes) kept, user-visible strings taken from the i18n
catalogs. Both trailing test suites in SettingsPage.test.tsx (pending
scopes + i18n wiring) kept.
@gitguardian

gitguardian Bot commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
35010655 Triggered Generic Password 3f2d512 web/src/routes/UsersPage.test.tsx View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

mforce added 2 commits July 27, 2026 22:11
Take main's fully externalized HelpPage; re-apply #236's additions on top:
the common-namespace hook and the workingHint list item in the dialogs
section (pinned by the existing catalog-override test).
The overlay version dimmed the label to 0.45 and stacked a 2px ring on
top of it — on filled buttons it read as barely-there (owner feedback on
PR #242). The ring now renders inline as the label wrapper's first child,
3px stroke, with the label at full brightness; the button widening
slightly while busy is the accepted trade-off since it is disabled for
the duration. Structure pinned by a new BusyButton test; reduced-motion
still gets a static ring via the global animation kill.
@mforce
mforce merged commit c5c939c into main Jul 28, 2026
7 checks passed
@mforce
mforce deleted the feat/236-save-pending branch July 28, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SPA: pending/busy state on every save — lock waits (#162) are invisible to the user

1 participant