Skip to content

Mutation re-entry guard — the pattern, and why a reactive :disabled isn't enough

Applies to: Any Vue composable or component handler that dispatches a GraphQL mutation via useMutation — anywhere a user action can fire the same write twice before the first one resolves.

The pattern

Guard synchronously, before the dispatch — not after, and not by relying on a template binding:

typescript
// ionic_frontend/src/composables/useGoalForm.ts:204-205 (createGoal), same shape at :291-292 (updateGoal)
if (isSubmitting.value) return null
isSubmitting.value = true

Three parts, all required:

  1. The early-return check comes first, synchronously, before anything async happens. There is no await between the check and the mutation dispatch — nothing gets a chance to interleave.
  2. The flag is set to true immediately after the guard, still synchronously, before the await addGoalMutation(...) call a few lines later (useGoalForm.ts:248).
  3. The flag is cleared in a finally block (useGoalForm.ts:274-276), so it resets on both the success and error paths — a guard that only clears on success leaves the UI permanently locked out after the first failed attempt.

This is the census script's own detection target (scripts/census-mutation-guards.mjs): a named function that sets an in-flight ref.value = true before calling a useMutation-destructured alias, then never reads that ref back in an early-return if. Line numbers drift — this doc's citations above were re-verified against the live file for this pass; .planning/milestones/v4.39-mutation-guard-census.md (a point-in-time record) still cites useGoalForm.ts:202-203, two lines earlier than the guard's real current location (:204-205) — a comment insertion shifted it once already this milestone. The census script's own header comment and the recurrence gate's error message were corrected to :204-205 in this pass (OBJ-2714). Don't copy a cited line number without re-checking it against the file.

Why a reactive :disabled binding is not sufficient

This is the part most likely to be re-lost, so state it plainly: binding a button's :disabled to the same in-flight ref does not prevent a re-entrant dispatch. It stops a second mouse click, because Vue's reactivity takes at least one tick to re-render the DOM after isSubmitting.value = true is set — but nothing stops two dispatches that happen inside the same synchronous browser event-handling task, before that re-render ever has a chance to happen. A raw same-tick double dispatch (two .click() calls inside one page.evaluate, or any other same-tick re-entry — a duplicate event listener, a fast double-tap on a touch device) fires the handler twice before the :disabled attribute has actually flipped.

This is the Throughline gate g27 shape — "no client-side debounce on a double-submit button" (quality-audit-frameworks.md:99's canonical example is this exact bug class: a double-submit "Create goal" button). It reached production once in this codebase before the synchronous-guard idiom above was the house standard everywhere; that's the reason this milestone (v4.39, OBJ-2671–2675) exists — a committed, re-runnable census plus a CI recurrence gate, not just a written rule.

The verification standard: a Vitest assertion is necessary, but not sufficient. A test that awaits between two calls to the handler never reproduces the failure mode above — it always finds the guard "working," because the await is exactly the yield point a same-tick double dispatch never gives the reactivity system. v4.37 Phase 3's plan (.planning/milestones/v4.37-flow-integrity-interstitial-arbitration-ROADMAP.md:547-549) states this directly, citing an earlier guard-verification pass as precedent: "Live confirmation required — the July pilot's raw-dispatch probe is the standard; a Vitest assertion alone repeats the mistake that let this ship." The reusable harness that implements this standard for this codebase today is ionic_frontend/scripts/probes/raw-dispatch-probe.mjs — see below.

This milestone produced its own fresh evidence for the same lesson, one layer over: useWhatsNew.ts's persistEpoch() guard (Phase 4, OBJ-2674) shipped a first version that cleared its in-flight flag after the first sub-attempt's settle inside acknowledgeWithRetry, rather than after the retry helper's terminal attempt — leaving a ~3-second window (the helper's ACK_RETRY_DELAY_MS, ionic_frontend/src/helpers/ackRetryQueue.ts:8) where a second dispatch could still get through while the first was mid-retry. Roy caught it in review, not in a failing test — the round-1 code's own unit tests passed. The fix and the reason are documented directly in the code: useWhatsNew.ts:113-116's comment ends (Roy review, OBJ-2674); ackRetryQueue.ts:58-61's onSettled parameter-behavior comment describes the same terminal-attempt semantics but doesn't carry that tag itself. Same bug class, in the patch written to close a re-entry gap, caught only by review — the concrete, non-hypothetical reason not to trust a green Vitest run alone on this pattern.

Verifying with the raw-dispatch probe

ionic_frontend/scripts/probes/raw-dispatch-probe.mjs drives a real Chromium browser (via Playwright) against a running Storybook instance and fires two same-tick clicks — both inside one page.evaluate call, no await between them — then reads back how many times the target GraphQL mutation actually reached the network layer, via a per-story counting Apollo link (window.__probeCounts).

bash
npm run storybook   # in ionic_frontend/, separate terminal, default port 6006

node scripts/probes/raw-dispatch-probe.mjs \
  --story <story-id-from-the-sidebar-or-url> \
  --operation GenerateMilestones \
  --button "button[data-testid=generate]"

Every probe target in this repo exposes its own RawDispatchProbe story — a thin wrapper component plus a .stories.ts file under the Dev/RawDispatchProbes/* title, independent of the shared .storybook/decorators/withApollo.ts decorator, so the counting link doesn't pick up unrelated mount-time queries. useGoalFormRawDispatchProbe.stories.ts (target: useGoalForm().generateMilestones), usePartnershipRawDispatchProbe.stories.ts, MeetCoachRawDispatchProbe.stories.ts, and TeamBillingTabRawDispatchProbe.stories.ts are the live examples from this milestone's Phase 2/3 batches — use one as the template for a new probe target, and copy its --story id from Storybook's own sidebar URL (?path=/story/<story-id>) rather than guessing the slug. To capture the required "without the guard" half of the evidence, temporarily delete the if (<flag>.value) return line, re-run, confirm the count goes to 2, then restore it.

The shared in-flight flag — cross-blocking trap

A naive per-site fix — add if (flag.value) return to every function that shares one composable-wide flag — makes those functions cross-block each other: guarding joinCommunity on the same loading ref that createCommunityPost also sets means creating a post refuses to start while an unrelated join is in flight. The fix is one in-flight ref per action, not a shared guard reused across actions.

composables/useCommunity.ts — six functions (joinCommunity, leaveCommunity, followCommunity, unfollowCommunity, createCommunity, createCommunityPost) used to share one loading ref. It's now six independent refs (joiningCommunity, leavingCommunity, followingCommunity, unfollowingCommunity, creatingCommunity, creatingCommunityPost, useCommunity.ts:10-15), with the old loading name kept as a computed OR of all six (useCommunity.ts:16-24) for any consumer that only needs "is anything in flight."

views/MeetCoach.vuesavePreferences() and resetToDefaults() used to share one saving ref; a save-in-flight would block a reset, and vice versa. Split into savingPreferences and resettingToDefaults (MeetCoach.vue:558-562), each guarding only its own function (:724-727, :766-768). A Vitest test proves resetToDefaults still starts while savePreferences is in flight — a shared-flag regression here would fail that test immediately, unlike the same-tick-dispatch gap above, since this is about two different actions never blocking each other, not about the same action's own guard surviving a raw double click.

When adding a new action to a composable or view that already tracks in-flight state for other actions: give it its own ref. Reusing an existing shared flag "because it's already there" is exactly how this trap gets reintroduced.

Running the census and the recurrence gate

bash
node scripts/census-mutation-guards.mjs               # census: lists every unguarded site, always exits 0
node scripts/check-mutation-guard-recurrence.mjs       # gate: exits 1 if any unguarded site isn't in the committed baseline

The census (scripts/census-mutation-guards.mjs) is the detection rule alone — a deterministic, re-runnable scan of ionic_frontend/src for the "sets an in-flight flag but never reads it in a guard" shape. It always exits 0; it reports, it doesn't gate. The recurrence gate (scripts/check-mutation-guard-recurrence.mjs) wraps it: every site the census finds must already be in the gate's committed BASELINE array (six individually-triaged, reasoned exceptions — see .planning/milestones/v4.39-mutation-guard-census.md for the full reasoning behind each), or the gate exits 1. A future unguarded site that isn't a baselined exception is a real regression, not a false positive to shrug off.

.github/workflows/mutation-guard-recurrence.yml runs both the gate and its regression test suite on every pull request that touches ionic_frontend/src/** or either script, and on every push to master. It runs and fails the check — it does not currently block a merge. The check is not in this repo's required-status-checks ruleset yet (tracked separately as OBJ-2708); a PR can merge while it's red. Treat a red run here as a real signal to act on, not as a safety net that already stops the bad merge for you.

The blind spot — verbatim, not paraphrased

Both the census and the gate share one documented, deliberate blind spot: a function that dispatches a mutation with no manual in-flight ref at all, relying solely on Apollo's own unread loading return value. There's no ref.value = true for the detection rule to find an absent guard against, so the site is structurally invisible to it — not a low-severity miss, an invisible one. Quoted verbatim from scripts/check-mutation-guard-recurrence.mjs's BLIND_SPOT_STATEMENT (paraphrasing this is how a known, explicitly-carved-out gap quietly turns back into an implied all-clear):

Blind-spot coverage: NOT extended. This gate inherits census-mutation-guards.mjs's documented blind spot for the "no manual in-flight ref at all" shape (a handler that dispatches a mutation relying solely on Apollo's own unread loading). The four 2026-08-18 confirmed instances of that shape were fixed in Phase 3; a future instance will NOT be caught by this gate. Carved out explicitly per OBJ-2675 Task 4 — see .planning/milestones/v4.39-mutation-guard-census.md's "Known blind spots" section.

The four 2026-08-18 instances it refers to (useStreakRepair.ts:75 repair(), usePartnership.ts:34/54/75 sendPartnerRequest()/acceptPartnerRequest()/declinePartnerRequest()) were each given a new, independent ref rather than retrofitted onto an existing one — the same discipline as the shared-flag trap above. If you're adding a mutation-dispatching handler and reach for const { mutate, loading } = useMutation(...) with no manual flag, that handler is exactly this blind spot's shape today — add a manual ref and the synchronous guard from the pattern above, don't rely on loading alone.

acknowledgeWithRetry's optional onSettled parameter

ionic_frontend/src/helpers/ackRetryQueue.ts:62's acknowledgeWithRetry(mutate, actionId, namespace?, onSettled?) gained an optional 4th parameter in Phase 4 (OBJ-2674). It fires exactly once, after the call's terminal attempt — the immediate attempt if it succeeds, otherwise the delayed retry's success or final failure (ackRetryQueue.ts:58-61) — not after every individual sub-attempt. useWhatsNew.ts:117-119's persistEpoch() uses it to hold epochSeedInFlight for the guard's full internal retry window, not just the first sub-attempt (see the Phase 4 round-1 bug above). It's opt-in: existing fire-and-forget callers that don't pass a 4th argument are unaffected — useWhatsNew.ts:182's persist() still calls acknowledgeWithRetry(tourAckMutate, JSON.stringify({ tourId, ...payload }), TOUR_ACK_NAMESPACE) with 3 arguments, unchanged, since the parameter is optional.

Current site count

Live, as of this doc (re-run node scripts/census-mutation-guards.mjs for the current number — do not trust this figure without re-running it):

6 unguarded sites remain, all individually dispositioned in the gate's BASELINE — 3 deferred-with-reason (idempotent by construction: two backend find_or_*/plain-update! idempotency guarantees, one debounced read-only check) + 3 excluded (script false positives — each is already guarded, just via an indirect computed read or, in one case, intentional re-entrancy with its own request-sequencing protection, that the detection regex can't see). Zero undispositioned rows. Full per-site reasoning for all 6: .planning/milestones/v4.39-mutation-guard-census.md.

This is a different number from the milestone's own inventory total (40 — the full census.md triage table, including every already-fixed row) and from the number of sites this milestone actually fixed (34). All three are true of a different population; see .planning/milestones/v4.39-mutation-guard-census.md's "Totals and reconciliation" section before quoting any of them out of context.

  • ionic_frontend/src/composables/useGoalForm.ts:204-205 (createGoal), :291-292 (updateGoal) — the reference idiom
  • ionic_frontend/src/composables/useCommunity.ts:8-24 — the per-action-ref split, plus the loading computed-OR back-compat shape
  • ionic_frontend/src/views/MeetCoach.vue:558-562 (refs), :724-727 (savePreferences), :766-768 (resetToDefaults) — the second shared-flag split
  • ionic_frontend/src/helpers/ackRetryQueue.ts, ionic_frontend/src/composables/useWhatsNew.ts — the optional onSettled parameter and its Phase 4 round-1 bug
  • scripts/census-mutation-guards.mjs, scripts/check-mutation-guard-recurrence.mjs, .github/workflows/mutation-guard-recurrence.yml — the census, the gate, and its CI wiring
  • ionic_frontend/scripts/probes/raw-dispatch-probe.mjs — the live verification harness
  • .planning/milestones/v4.39-mutation-guard-census.md — full per-site inventory and reasoning
  • Common Gotchas § Shared in-flight flag causes cross-blocking between unrelated actions
  • docs/milestones/v4.39-mutation-re-entry-guard-sweep.md — milestone narrative

Last updated: 2026-08-18 (v4.39 Phase 6, OBJ-2676 — new doc, written after Phases 1–5 (OBJ-2671–2675) shipped)

Loading…