ADR-031: Transactional Revert on Content Save Failure

Prev Next

Context

Content Details saves dirty React Hook Form fields through GraphQL via useContentDetails.save(). On success, the form is reset so dirty markers clear. On failure, the previous behavior only toasted an error and left dirty markers in place.

That created a persistent failure loop: later saves kept resending the same failing payload. GraphQL mutations are all-or-nothing, so the correct rollback unit is every top-level key included in the failed request payload (dirty fields plus any pendingFields).

Many call sites invoke save() / saveAfterTick() without awaiting completion or checking loading state, allowing overlapping save attempts. A naive failure revert for an older attempt can undo values belonging to a newer attempt.

A decision was needed on how save failures should restore form state without wiping unrelated in-progress edits or racing newer saves.

Decision

On content save failure, useContentDetails.save() performs a transactional field-level revert of the attempted payload keys:

  1. Capture attemptedKeys = Object.keys(payload) before the mutate call.
  2. On failure, restore each attempted key with React Hook Form resetField(key, { keepDirty: false, keepTouched: false }), which restores the last-known-good defaults (from the last successful reset / load), not the failed edit values.
  3. Guard overlapping saves with a save-generation counter: increment at the start of each mutate attempt; in catch, revert only when the failing attempt’s generation is still the current generation.
  4. Never update contentData on failure (server state is unchanged).
  5. Do not full-form reset on failure (that would wipe unrelated dirty fields).
  6. Do not parse GraphQL errors to infer per-field blame; the rollback set is the payload key set.

Image

Consequences

  • Failed saves no longer leave attempted fields dirty, so subsequent saves do not automatically resend the same failing payload.
  • Users see last-known-good values for failed keys while unrelated dirty fields remain editable.
  • Overlapping blur saves remain allowed; stale failure handlers cannot clobber a newer attempt.
  • Call sites that already handle save() === false (for example channels local list revert) remain complementary to hook-level resetField.
  • No save queue/mutex is introduced; serializing saves would make “busy” look like failure for callers that treat false as a hard revert signal.
  • updateContentState (empty field payload) is unchanged by this decision.

Notes (Optional)

  • Related: ADR-008: Registry-Driven Content Details Page
  • Implementation lives in src/hooks/content/useContentDetails.ts; coverage for failure/revert/generation branches is required at 100% branch coverage for those paths.