perf(macos): eliminate redundant recomputeDerivedProperties during messaging#31804
Conversation
…ssaging - Guard conversations.didSet with equality check to skip recompute when array content is unchanged (streaming activity snapshots) - Coalesce handleAssistantMessageArrival into single write via applyLastInteracted value-level helper (2 recomputes → 1) - Guard individual writebacks (title, inferenceProfile, attention merge, title SSE) against no-op mutations - AGENTS.md: add didSet/writeback guard guideline Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
✦ APPROVE
Value: Eliminates the root cause of 7 confirmed Sentry hang issues (303 events, ~57 users) by cutting redundant recomputeDerivedProperties() calls during streaming — the most frequent path in normal messaging flow.
What this does: Three-layer guard strategy — (1) didSet guard on the array, (2) value-level helper extraction to collapse 2× writes into 1× during handleAssistantMessageArrival, (3) call-site guards on individual write methods that fire during steady-state streaming.
ConversationModel.== — verified comprehensive: The guard conversations != oldValue guard's safety depends entirely on == covering all mutable stored properties. Verified at HEAD: the custom == compares all 16 stored fields (id, title, createdAt, conversationId, isArchived, groupId, displayOrder, lastInteractedAt, source, conversationType, inferenceProfile, scheduleJobId, hasUnseenLatestAssistantMessage, latestAssistantMessageAt, lastSeenAssistantMessageAt, forkParent expanded to 3 sub-fields, originChannel). All computed properties (isPinned, isBackgroundConversation, etc.) derive from stored fields — captured indirectly. No false-negative skip risk. ✅
Anti-patterns KB — directly on-point: The KB's Observation rule explicitly states "DO NOT write to @observable properties without first checking != old value — Observation fires on any withMutation regardless of whether value changed." Every change in this PR enforces exactly that rule, at both the didSet boundary and the write sites.
applyLastInteracted(into:) extraction — correct:
@discardableResultis appropriate —updateLastInteracted(the wrapper) calls it without needing the return value, whilehandleAssistantMessageArrival(the coalescing caller) captures it forsendPinChange.sendPinChangecall moved fromConversationListStoretoConversationManagerafter the single writeback — ordering preserved (fires afterconversations[index] = conversation). Typecheck clean confirms accessibility.
Individual call-site guards — each verified:
updateConversationTitle/renameConversation/handleConversationTitleUpdated: guard against daemon pushing the same title again (common during sync on reconnect).updateConversationInferenceProfile: same pattern, same reasoning.mergeAssistantAttention:guard conversation != conversations[index]is the most general form — applies the full==afterapplyAssistantAttentionmutates the local copy, before writeback. Correct.
Concurrency — clean: All methods are synchronous on @MainActor. No new async work introduced. applyLastInteracted(into:) takes inout ConversationModel — pure local mutation, no shared state access. No race conditions introduced.
Non-blocking: groups.didSet still lacks the != oldValue guard. groups mutates far less often than conversations (no streaming path touches it), so it's not a hot path concern. Worth a follow-on if groups ever shows up in Sentry.
Vellum Constitution — Trust-seeking: 303 events of "app appears frozen during messaging" replaced with a single idempotency check that the framework's own docs say is the correct pattern.
Prompt / plan
Sentry hang analysis identified 7 issues (303 events, ~57 users) all rooted in
ConversationListStore.recomputeDerivedProperties()blocking the main thread during messaging. The method runs synchronously inconversations.didSetand performs O(N log N) sorts, 8+ array equality comparisons (each O(N×18) forConversationModel), and triggers synchronous SwiftUI graph invalidation via@Observableproperty writes.Two prior PRs addressed the bulk case (snapshot-coalescing for batch handlers, cached derived properties with
setIfChanged). This PR targets the remaining hot paths: individual-mutation callers that fire during normal messaging flow.Changes
1. Guard
conversations.didSetagainst no-op mutationsArray subscript assignment (
conversations[idx] = value) firesdidSeteven when the new value equals the old. During streaming,handleAssistantMessageArrivalfires on everyAssistantActivitySnapshotchange — but most invocations don't actually modify the conversation (e.g., active conversation is already marked seen). The guard eliminates these redundant recomputes entirely.2. Coalesce
handleAssistantMessageArrivalinto a single writePreviously this method called
listStore.updateLastInteracted()(write #1 → recompute) thenlistStore.conversations[index] = conversation(write #2 → recompute) — 2 full recomputes per new assistant message.Extracted
applyLastInteracted(into:)as a value-level helper (same pattern asapplyAssistantAttention(from:into:)) so the caller applies last-interacted logic to a local snapshot and writes back once.3. Guard individual writebacks at call sites
Added early-return guards to
updateConversationTitle,updateConversationInferenceProfile,renameConversation,handleConversationTitleUpdated, andmergeAssistantAttention— all sites where the daemon may push unchanged data that would otherwise trigger a full recompute.4. AGENTS.md guideline
Added
didSet/writeback guard pattern to the coalescing guideline, documenting that array subscript assignment firesdidSetunconditionally and how to guard against it.Alternatives not taken
recomputeDerivedPropertiesto next RunLoop turn): Risk of stale derived properties — callers likeappendConversations→onConversationsAppended?()may read derived state synchronously after writing.@ObservationIgnored+access(keyPath:)management — high complexity, error-prone.sidebarGroupEntriesinto per-group properties to reduce notification blast radius — large refactor for marginal gain since most changes affect the "All" group.Root cause analysis
conversations.didSetalways calledrecomputeDerivedProperties()unconditionally. Swift's value-type array semantics mean subscript writes (array[i] = x) triggerdidSeteven whenx == array[i]. The snapshot-coalescing PR fixed batch handlers but individual-mutation callers (especiallyhandleAssistantMessageArrival) were left untouched.handleAssistantMessageArrivalpath wasn't included in the coalescing PR because it was a single-element write — but it actually does two writes (one viaupdateLastInteracted, one direct), and fires on every activity snapshot during streaming.sidebarGroupEntries.setter→ SwiftUI graph walk duringrecomputeDerivedProperties— confirming even a single recompute is expensive enough to hang for 2s+ with large conversation lists.didSetguard pattern.Test plan
conversations != oldValueguard preserves existing behavior: when content actually changes,recomputeDerivedPropertiesruns exactly as beforeConversationListStoreObservationTestsverify observation still fires on real mutationsLink to Devin session: https://app.devin.ai/sessions/5ce4b4afebca4589b174078c8ef454ba
Requested by: @ashleeradka