Skip to content

feat(web): add onMutate optimistic setQueryData with field-level rollback to useDaemonConfigMutation (LUM-2213)#33191

Merged
vex-assistant-bot[bot] merged 3 commits into
mainfrom
devin/1780451913-lum-2213-optimistic-setquerydata
Jun 3, 2026
Merged

feat(web): add onMutate optimistic setQueryData with field-level rollback to useDaemonConfigMutation (LUM-2213)#33191
vex-assistant-bot[bot] merged 3 commits into
mainfrom
devin/1780451913-lum-2213-optimistic-setquerydata

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Prompt / plan

Closes LUM-2213. Follow-up to PR #33171 (LUM-2186, query-derived draft state).

PR #33171 introduced a query-derived draft state pattern where useEffect auto-clears drafts when the server value converges. This works correctly but has a cosmetic trade-off: during the ~50ms cache refetch window between mutateAsync resolving and invalidateQueries completing, configChanged is true and the save button briefly re-enables.

This PR eliminates that window by optimistically updating the TanStack Query cache in onMutate before the server responds.

Why needed

When a user saves settings, the mutation sends a PATCH and then onSettled calls invalidateQueries to refetch. Between mutation completion and refetch completion, the cached config still holds stale values. Consumer-derived state (serverWebSearchMode, serverImageGenMode, effectiveActiveProfile) reads stale, causing configChanged to briefly be true.

Benefits

  • Save button stays disabled during the refetch window (no flicker)
  • No new state layers — uses TanStack Query's built-in optimistic update pattern
  • useEffect auto-clear from LUM-2186 fires immediately instead of after refetch
  • Automatic field-level rollback on mutation failure via onError
  • Concurrent-mutation safe — rollback only affects the fields this mutation touched

Why safe

  1. Additive onlyonMutate/onError are new callbacks alongside existing onSettled. No existing behavior removed.
  2. Pure functionsapplyConfigPatch and snapshotPatchedFields are pure functions with 20 unit tests covering all merge and rollback edge cases.
  3. Field-level rollbackonError restores only the fields this mutation touched (via snapshotPatchedFields), not the full config snapshot. This follows the STATE_MANAGEMENT.md concurrent mutation rule — if mutation A fails while mutation B's optimistic update is in the cache, A's rollback doesn't clobber B's changes.
  4. onSettled refetch is authoritative — the optimistic data is always replaced by the server's real response on settle, so any divergence between optimistic and real data is transient.
  5. Graceful degradation — if assistantId is undefined (loading window), onMutate returns early and the mutation works without optimistic updates.
  6. All 57 domain tests pass (20 new + 37 existing).

Approach — TanStack Query optimistic updates

Follows the TanStack Query optimistic updates pattern and TkDodo's concurrent optimistic updates guidance:

  1. onMutate: Cancel in-flight refetches → snapshot only the fields the patch touches (snapshotPatchedFields) → apply patch optimistically (applyConfigPatch)
  2. onError: Roll back only the changed fields using an updater function — concurrent mutations' optimistic data stays intact
  3. onSettled: Invalidate cache (existing behavior, unchanged)

applyConfigPatch deep-merge semantics

The daemon uses deep-merge PATCH semantics:

  • Omitted keys are left unchanged
  • null at record-entry positions deletes the entry (profiles) or resets it (callSites)
  • Nested objects are merged field-by-field, not replaced

applyConfigPatch replicates these semantics for the client-side cache. It handles all consumer patch shapes: service mode/provider changes, activeProfile, profileOrder, profile CRUD, and call-site override CRUD.

snapshotPatchedFields — field-level rollback

Per STATE_MANAGEMENT.md:343-349, full-snapshot rollback is unsafe when concurrent mutations are possible. Since useDaemonConfigMutation is shared across 5+ components (web-search-card, image-generation-card, language-model-card, call-site-overrides-modal, manage-profiles-modal), concurrent mutations are entirely possible.

snapshotPatchedFields(config, patch) captures only the previous values of fields the patch will touch, returning a DaemonConfigPatch-shaped snapshot. On error, applyConfigPatch(cache, snapshot) restores exactly those fields via an updater function, leaving the rest of the cache intact.

Alternatives considered and rejected

  1. Eager draft clearing in save handler — Rejected in LUM-2186. Clearing drafts before the cache updates causes the UI to flash stale server values.
  2. Third state layer (savedOverride) — Considered during LUM-2186 review. Adds complexity (three layers: server, saved, draft) without addressing the root cause. Optimistic setQueryData is simpler and eliminates the need.
  3. Generic deep-merge utility — Could have used a recursive generic merge. Rejected because daemon-specific semantics (null = delete for profiles, null = reset for callSites) require explicit handling per field type. A type-aware function is safer than a generic one.
  4. Full-snapshot rollback in onError — Initial implementation saved the entire previous config and restored it wholesale. Rejected per STATE_MANAGEMENT.md concurrent mutation rule — this would silently revert other mutations' optimistic updates. Replaced with field-level rollback via snapshotPatchedFields.

Test plan

  • 20 new unit tests: 15 for applyConfigPatch (service merges, null deletion, activeProfile/profileOrder updates, default merge/delete, profile entry merge/add/delete, callSite merge/add/null-reset, empty patch, sparse config, immutability) + 5 for snapshotPatchedFields (service-only snapshot, missing entries, profile-scoped snapshot, independent field snapshot, concurrent mutation integration test)
  • 37 existing domain tests pass (no regressions)
  • TypeScript: zero new errors
  • ESLint: zero new warnings

Link to Devin session: https://app.devin.ai/sessions/4b3b130bbf274e5487da3b4992883093
Requested by: @ashleeradka

…ation (LUM-2213)

Optimistically update the daemon config query cache before the server
responds, eliminating the brief window where derived state (configChanged,
save button enabled) reverts to stale values during the refetch window.

- Add applyConfigPatch() pure utility: deep-merges a DaemonConfigPatch
  into a cached DaemonConfig, mimicking the daemon's merge semantics
  (omitted keys unchanged, null deletes at record-entry positions)
- Add onMutate callback: cancels in-flight refetches, snapshots the
  current cache, applies the patch optimistically
- Add onError callback: rolls back cache to pre-mutation snapshot
- 15 unit tests for applyConfigPatch covering all merge edge cases

Closes LUM-2213
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@linear

linear Bot commented Jun 3, 2026

Copy link
Copy Markdown

LUM-2213

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread apps/web/src/domains/settings/ai/use-daemon-config.ts
…UM-2213)

Replace full-snapshot rollback with field-level rollback per
STATE_MANAGEMENT.md concurrent mutation rule.

- Add snapshotPatchedFields() — captures only the fields the patch
  touches, so onError restores just those fields via an updater
  function, preserving any concurrent mutation's optimistic data.
- 5 new tests for snapshotPatchedFields including a concurrent
  mutation integration test proving field-level rollback preserves
  the other mutation's changes.
@ashleeradka

Copy link
Copy Markdown
Contributor

@codex review

@devin-ai-integration devin-ai-integration Bot changed the title feat(web): add onMutate optimistic setQueryData to useDaemonConfigMutation (LUM-2213) feat(web): add onMutate optimistic setQueryData with field-level rollback to useDaemonConfigMutation (LUM-2213) Jun 3, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vex APPROVE ✦

LUM-2213 — onMutate optimistic setQueryData with field-level rollback. Closes the cosmetic save-button-re-enable trade-off Devin explicitly deferred from #33171.

Verified at HEAD b7079d89:

  • Lifecycle shape is TanStack canonical: cancelQueries → snapshot → setQueryData (updater form) → onError rollback → onSettled invalidate. Matches the LUM-2173 sounds-mutation pattern (#33044) and the #33094 architecture work.
  • Field-level rollback is correct per STATE_MANAGEMENT.md:343-349. snapshotPatchedFields captures only the axes the patch touches; onError applies that snapshot back through applyConfigPatch, leaving concurrent mutations' optimistic data intact. The integration test (field-level rollback preserves concurrent mutation) walks the exact A-fails-while-B-pending scenario the doc rule guards against.
  • Merge semantics mirror the daemon: services/profile-entry/default null = delete; callSites null = explicit reset-to-inherit (NOT delete) — and the snapshot path correctly stores null for previously-absent entries so rollback restores the same effective state. Test null callSite sets value to null (reset override) pins this distinction.
  • Graceful degradation: if (!assistantId) return in onMutate means the mutation still works during the assistant-list loading window (returns undefined context, onError no-ops). Compatible with the lazy-resolve path in mutationFn.
  • Authoritative refetch preserved: onSettled still uses resolvedId ?? assistantId so the existing #33156 lazy-resolve invalidation behavior is intact — optimistic data always gets replaced by the server response.
  • Devin self-resolution loop verified: initial 69f0894c had full-snapshot rollback in onError; Devin's own BUG_pr-review-job (citing STATE_MANAGEMENT.md) caught it; fixed in f498b388 by introducing snapshotPatchedFields. Finding resolved at HEAD.
  • Codex 👍 at HEAD after Boss's @codex review trigger.

Anti-pattern grep on diff (added lines only):

  • Zero as casts on runtime-boundary shapes
  • Zero non-null ! in production code
  • Zero @ts-ignore / eslint-disable
  • Zero || 0 fallbacks
  • Zero raw Sentry.captureException (no telemetry changes)

Test coverage (252 new lines): 13 applyConfigPatch tests covering all 6 patch axes (services merge/delete, activeProfile, profileOrder replace, default merge/delete, profile-entry merge/delete/add, callSite merge/null-reset/add) + immutability + empty-patch + sparse-config edges. 5 snapshotPatchedFields tests including the concurrent-mutation rollback walk.

CI 7/7 green. mergeable_state: blocked is the standard Devin-last-pusher convention — bot APPROVE at HEAD satisfies branch protection.

Merging.

@vex-assistant-bot vex-assistant-bot Bot merged commit 86b2e8b into main Jun 3, 2026
7 checks passed
@vex-assistant-bot vex-assistant-bot Bot deleted the devin/1780451913-lum-2213-optimistic-setquerydata branch June 3, 2026 02:24
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.

1 participant