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:
// ionic_frontend/src/composables/useGoalForm.ts:204-205 (createGoal), same shape at :291-292 (updateGoal)
if (isSubmitting.value) return null
isSubmitting.value = trueThree parts, all required:
- The early-return check comes first, synchronously, before anything async happens. There is no
awaitbetween the check and the mutation dispatch — nothing gets a chance to interleave. - The flag is set to
trueimmediately after the guard, still synchronously, before theawait addGoalMutation(...)call a few lines later (useGoalForm.ts:248). - The flag is cleared in a
finallyblock (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).
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.vue — savePreferences() 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
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 baselineThe 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.
Related
ionic_frontend/src/composables/useGoalForm.ts:204-205(createGoal),:291-292(updateGoal) — the reference idiomionic_frontend/src/composables/useCommunity.ts:8-24— the per-action-ref split, plus theloadingcomputed-OR back-compat shapeionic_frontend/src/views/MeetCoach.vue:558-562(refs),:724-727(savePreferences),:766-768(resetToDefaults) — the second shared-flag splitionic_frontend/src/helpers/ackRetryQueue.ts,ionic_frontend/src/composables/useWhatsNew.ts— the optionalonSettledparameter and its Phase 4 round-1 bugscripts/census-mutation-guards.mjs,scripts/check-mutation-guard-recurrence.mjs,.github/workflows/mutation-guard-recurrence.yml— the census, the gate, and its CI wiringionic_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)