Skip to content

Cache derived properties on ConversationListStore to eliminate observation cascades#29898

Merged
ashleeradka merged 4 commits into
mainfrom
devin/1778178866-lum-1429-isolate-conversations-observation
May 7, 2026
Merged

Cache derived properties on ConversationListStore to eliminate observation cascades#29898
ashleeradka merged 4 commits into
mainfrom
devin/1778178866-lum-1429-isolate-conversations-observation

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 7, 2026

Closes LUM-1429 and three sibling app-hangs (MACOS-NK, MACOS-TG, MACOS-VR, MACOS-PR) — multi-second main-thread hangs whose stacks land in ConversationListStore.<setter>withMutationKeyPath._projectReadOnly.

Why

The Observation framework tracks at the property level, not the value level. Every assignment to an @Observable stored property fires the willSet hook and runs every withObservationTracking callback synchronously, regardless of value equality. SwiftUI runs the resulting graph update inside that hook. When a view body or .onChange(of:) modifier reads anything off the live conversations array (.isEmpty, .count { … }, .contains { … }, .first { … }), it subscribes to every mutation of the array — pagination, heartbeat re-fetch, per-message seen flip, in-place model edit — and stalls the main actor for the duration of the resulting graph update.

The cache-and-invalidate pattern was already partially established but four direct subscribers slipped through. MACOS-TG's stack lands literally inside recomputeDerivedPropertiesgroupedConversations.setterwithMutation, confirming the missing != guards on its writes — the function's own comment documents why guards are required, but the discipline was applied to recomputeSidebarGroupEntries only.

What

  1. Cached scalars and lookup on ConversationListStorehasAnyConversations, hasAnyVisibleConversations, unseenScheduledCount, and conversationsByLocalId (O(1) lookup), all maintained in recomputeDerivedProperties() behind != guards.
  2. Cached Set<UUID> on ConversationSelectionStorevisibleNonArchivedConversationIds for selection-validation .contains checks, synced via the existing onDerivedPropertiesRecomputed callback alongside syncActiveConversationCache.
  3. != equality guards on the four unguarded writes in recomputeDerivedProperties (sortedGroups, visibleConversations, unseenVisibleConversationCount, groupedConversations).
  4. @ObservationIgnored on ConversationListStore.conversations so any future view-body read of the raw array is a silent no-op rather than an O(N²) anti-pattern. The doc-comment documents the rule.
  5. archivedConversations converted from computed → cached to satisfy step 4 for the Settings → Archive tab.
  6. All consumer view bodies, action handlers, and DropDelegate methods route through the cached scalars / conversationsByLocalId lookup — replaces O(N) scans with O(1) and eliminates accidental observation subscriptions.
  7. AGENTS.md — the existing "Cache derived booleans from high-frequency collections" rule is extended with .first / .contains and the @ObservationIgnored enforcement pattern.

Benefits

  • Eliminates the 2 s+ main-thread hangs by suppressing observation cascades on no-op writes and removing every KeyPath._projectReadOnly traversal from the observation graph.
  • Reduces O(N) scans (first(where:), contains(where:)) to O(1) dictionary / set lookups across drag, drop, and per-conversation view paths.
  • Architectural lock-in: marking the underlying array @ObservationIgnored makes the next instance of the anti-pattern a code-review issue rather than a production hang.

Why it's safe

  • Same cache-and-invalidate pattern already used for sidebarGroupEntries / systemSidebarGroupEntries / customSidebarGroupEntries and activeConversation. Cache freshness is guaranteed because recomputeDerivedProperties is the single mutation funnel and runs synchronously in didSet.
  • All mutations to conversations (assignment, append, insert, remove, subscript, in-place field edits) trigger didSet. @ObservationIgnored only affects observation tracking, not setter semantics.
  • archivedConversations now sorts on every conversations mutation rather than lazily on Settings open. For typical N this is dwarfed by the existing visibleConversations sort in the same function and skipped on no-op writes by the != guard.

References

Alternatives considered and rejected

  • Defer the conversations setter to Task { @MainActor in … } — moves the synchronous cost one tick later without addressing the root cause. The next mutation hits the same cascade.
  • withTransaction(.init(animation: nil)) / disablesAnimations — affects animations only. Does not suppress @Observable change notifications, didSet, or the synchronous SwiftUI graph update inside willSet.
  • Skip @ObservationIgnored (step 4) for safety — the same anti-pattern slipped past two prior fixes. Making accidental view-body reads a silent no-op (rather than working but expensive) lets the cached-scalar discipline lock in by default. The tradeoff: the next instance silently stops reacting rather than working but slow, which is caught at PR review rather than as a production hang. The doc-comment on conversations calls out the rule explicitly.
  • Cache the selection-validation Set<UUID> on ConversationListStore instead of ConversationSelectionStore — placement on ConversationSelectionStore keeps cross-store reads off the hot list-mutation path, matching the precedent set by syncActiveConversationCache.

Root cause analysis

  1. How did the code get into this state? Each surviving direct subscriber pre-dated the cache-and-invalidate pattern and was left in place because the pattern was applied incrementally as individual hangs were investigated. Each hot path was fixed one at a time, leaving the cold paths in place under the assumption that they did not contribute meaningfully.
  2. What decisions led to it? Treating each @Observable instance as if SwiftUI would diff by value — it diffs by property identity. .onChange(of: collection.derivedScalar) reads as cheap inline computation, but the Observation framework registers a subscription on the collection itself.
  3. Warning signs we missed? MACOS-TG's stack pointed straight at the unguarded recomputeDerivedProperties writes; the function's own comment documented why guards were required, but the discipline was applied to recomputeSidebarGroupEntries only. Each new hang was triaged as a fresh issue rather than as the same root cause as the prior one.
  4. Prevention? Marking conversations @ObservationIgnored makes the next accidental view-body read a silent no-op rather than a working-but-expensive read, surfacing the issue at PR review rather than in production hang telemetry. The cached lookup and scalars are the only supported access paths from view bodies.
  5. AGENTS.md addition? The existing "Cache derived booleans from high-frequency collections" rule is extended with .first / .contains (the variants that were missed) and the @ObservationIgnored enforcement pattern. Kept lightweight and link-heavy per project doc conventions.

Reviewer checklist

  • Local Xcode build — CI skips macOS checks; please verify the project still builds cleanly before merging.
  • Settings → Archive tab — verify archived conversations still appear and sort correctly after archive / unarchive (now driven by the new cached array).
  • Pop-out thread window title — verify the title bar updates reactively when the conversation is renamed (the cached lookup is observed; the raw array is not).
  • Drag-and-drop in the sidebar — verify drop validation, indicators, and pin-toggle still work (drop delegates and the pin-toggle action handler now read through conversationsByLocalId).

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


Open in Devin Review

@linear
Copy link
Copy Markdown

linear Bot commented May 7, 2026

LUM-1429

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 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 and CI monitoring

…ation cascades

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 784217c501

ℹ️ 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".

/// equality guards. Mutations from `ConversationManager` and
/// `ConversationRestorer` continue to write through this property; the
/// `didSet` keeps the cached views in sync.
@ObservationIgnored var conversations: [ConversationModel] = [] {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep thread titles on an observable cache

With conversations now @ObservationIgnored, the still-existing ThreadWindowContentView.title computed property reads conversationManager.conversations, so a popped-out thread no longer subscribes to rename/backfill mutations; after renaming that conversation, the cached conversationsByLocalId readers update but the title bar can remain stale. Please migrate that title lookup (and other view-time reads) to an observable cached property before ignoring the backing array.

Useful? React with 👍 / 👎.

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.

Good catch — fixed in 381158f. ThreadWindowContentView.title now reads through conversationManager.listStore.conversationsByLocalId[conversationLocalId]?.title (the cached, observed lookup) instead of the now-@ObservationIgnored conversations array. Same migration applied to the lookup in popOutToWindow(_:) for consistency.

Swept the rest of the codebase in the same pass for any remaining direct view-body / drop-delegate reads of .conversations:

  • SidebarView row construction (pin-toggle action handler)
  • SidebarSectionView (group-id lookup for drop targeting)
  • SidebarSectionHeaderDropDelegate (validateDrop, performDrop, performConversationDrop)

All migrated to conversationsByLocalId for O(1) lookup and observation correctness. Resolving.

Copy link
Copy Markdown
Contributor

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

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

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.

🔴 ThreadWindowContentView.title reads @ObservationIgnored conversations in view body — title never updates on rename

The title computed property at ThreadWindow.swift:192-194 reads conversationManager.conversations, which forwards to listStore.conversations (ConversationManager.swift:109). Since this PR marks conversations as @ObservationIgnored (ConversationListStore.swift:53), SwiftUI's Observation framework no longer tracks this read during body evaluation. The title bar in pop-out thread windows will never reactively update when the conversation is renamed — directly contradicting the comment on line 190–191 which says it "updates reactively when the conversation is renamed, avoiding the stale-title bug."

The sibling conversation property at ThreadWindow.swift:351-353 was correctly migrated to use conversationsByLocalId (which IS observed), but title was missed in the same migration pass.

(Refers to lines 192-194)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Confirmed and fixed in 381158f. title now routes through conversationManager.listStore.conversationsByLocalId[conversationLocalId]?.title, which IS observed, so the title bar updates reactively on rename. The lookup in popOutToWindow(_:) was migrated in the same pass for consistency. Resolving.

…s review feedback

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1778178866-lum-1429-isolate-conversations-observation branch from 784217c to 381158f Compare May 7, 2026 18:53
@ashleeradka ashleeradka requested a review from noanflaherty May 7, 2026 19:02
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

APPROVE

Value: Eliminates multi-second main-thread hangs (MACOS-NK, -TG, -VR, -PR) for every v0.8.0 user — 8 events / 5 users active today. This is LUM-1429 plus 3 sibling Sentry fingerprints, all rooted in the same ConversationListStore.withMutation cascade.

What this does: Four-part cache-and-invalidate fix. (1) @ObservationIgnored on conversations eliminates the O(N²) observation cascade — every per-message seen-flip, heartbeat re-fetch, or pagination pulse was synchronously invalidating every subscriber. (2) Four new cached scalars (hasAnyConversations, hasAnyVisibleConversations, unseenScheduledCount, conversationsByLocalId lookup dict) give view bodies narrow observable targets instead of the full array. (3) != guards added to the four previously unguarded writes in recomputeDerivedProperties — specifically the sortedGroups, visibleConversations, unseenVisibleConversationCount, and groupedConversations writes that MACOS-TG's stack was landing in. (4) visibleNonArchivedConversationIds: Set<UUID> on ConversationSelectionStore — same pattern applied to the two selection-validation .contains scans in MainWindowView.

Checked against our KB:

  • Follows the established recomputeDerivedProperties + != guards pattern from patterns.md (Equality guards on @observable property writes). This PR extends it to the 4 cases that slipped through.
  • No new withObservationTracking loops. No .frame(maxWidth:). FlexFrame Lint ✅.
  • @ObservationIgnored comments are thorough and accurate — doc-comment on the property cleanly documents the contract for future contributors.
  • Consumer sweep: all 7 direct .conversations reads in drop delegates and action handlers migrated to conversationsByLocalId O(1) lookups ✅. All remaining .conversations references in SidebarView / SidebarSectionView / ConversationSwitcherDrawer are SidebarGroupEntry.conversations struct fields (off the observable sidebarGroupEntries cache) — not raw listStore.conversations reads ✅.
  • ConversationModel: Hashable!= guard on conversationsByLocalId: [UUID: ConversationModel] is valid ✅.
  • onDerivedPropertiesRecomputed not called from the standalone recomputeArchivedConversations path: intentional — archive-only mutations (timestamp dict changes) don't affect visibleConversations or visibleNonArchivedConversationIds, so the selectionStore sync is correctly skipped.

Devin P1 / Codex P2 finding — both confirmed fixed at current HEAD (381158ffb8): Both bots independently caught that ThreadWindowContentView.title still read through conversationManager.conversations (now @ObservationIgnored), which would have permanently stalled the pop-out thread title bar on rename. Fixed: migrated to conversationManager.listStore.conversationsByLocalId[conversationLocalId]?.title. ✅

One robustness note (non-blocking): Dictionary(uniqueKeysWithValues:) will trap if conversations ever contains duplicate UUIDs. UUIDs are UUID() at creation so this is theoretical, but Dictionary(conversations.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) would be the defensive form. Not a blocker — worth keeping in mind for the future.

Bot status: Devin's "4 additional findings" in the Devin Review UI aren't visible via the GitHub API — inline comments show all findings self-resolved at this SHA, but a quick check at https://app.devin.ai/review/vellum-ai/vellum-assistant/pull/29898 before merging is worth it. CI: Socket Security ✅, FlexFrame Lint ✅, macOS Build/Tests SKIPPED (expected).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

Thanks for the thorough review.

Rolled the non-blocking robustness suggestion in as 923a79dconversationsByLocalId now uses Dictionary(_, uniquingKeysWith: { _, new in new }) so a theoretical duplicate UUID cannot trap. Cheap defensive change, last-write-wins matches the implicit semantics of the previous form for the (impossible-but-cheap-to-defend) collision case.

Left the two pre-existing Dictionary(uniqueKeysWithValues:) calls on groups.map { ($0.id, $0.sortPosition) } (lines 310 and 694) alone — same theoretical concern but out of scope for this PR's observation/caching focus. Happy to follow up separately if useful.

…struct, terser cached-property docs, migrate remaining O(N) scans

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit 5c8e565 into main May 7, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1778178866-lum-1429-isolate-conversations-observation branch May 7, 2026 19:30
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