perf(chat): cache isPaginatedEmpty to decouple outer ChatView.body from message-array observation (LUM-1330)#29227
Conversation
🤖 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:
|
…sibleMessages (LUM-1330) Add isPaginatedEmpty stored boolean to ChatPaginationState, updated via write-only-on-change guard in recomputePaginatedSuffix(). ChatView's isEmptyState/shouldShowSkeleton now read this boolean instead of accessing the full paginatedVisibleMessages array. This removes the outer ChatView.body observation dependency on the high-frequency array property, allowing the existing ObservationBoundaryView (from LUM-1273) to actually isolate message-list observation. Previously, shouldShowSkeleton — evaluated in .onChange(of:) outside the boundary — tracked the array, causing outer body re-evaluation on every message mutation during streaming. Closes LUM-1330 Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…equency collections Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
0dd542c to
9307b17
Compare
There was a problem hiding this comment.
✦ APPROVE
Value: Eliminates the outer ChatView.body re-evaluation on every streamed token — stops redundant reconstruction of the GeometryReader, ZStack, and ~12-modifier chain that was firing for every message mutation during streaming. Direct fix for Sentry MACOS-RJ / LUM-1330.
What this does: Adds isPaginatedEmpty: Bool as a cached stored property on ChatPaginationState, updated via defer in recomputePaginatedSuffix() with a write-only-on-change guard. Replaces the two paginatedVisibleMessages.isEmpty reads in ChatView with isPaginatedEmpty — so the outer body only observes a boolean that changes on empty↔non-empty transitions instead of the full message array that mutates on every streamed token.
Correctness verified:
- All 4 mutation paths covered.
paginatedVisibleMessagesisprivate(set)and is only assigned insiderecomputePaginatedSuffix()at lines 180, 194, 198, 200 — all are within the same function, so thedeferfires after every path including early returns. ✅ - defer ordering correct. Registered at the top of the function, executes after all assignments, reads the final value of
paginatedVisibleMessageson every exit path. ✅ - Consistency invariant preserved.
isPaginatedEmptyis updated synchronously in the same call that updatespaginatedVisibleMessages— no observer can read them in a split state. ✅ - Initial value correct.
isPaginatedEmpty = truematchespaginatedVisibleMessages = []at init time. Theinitcall torecomputeVisibleMessageswill sync it immediately if seeded messages are non-empty. ✅ - Write-only-on-change guard effective. During streaming, the array is never empty, so
newEmpty == isPaginatedEmpty == false→ the guard short-circuits, zero observation events emitted. The outer body stays silent for the entire streaming window. ✅ - No remaining raw
.isEmptycall sites. The grep confirms the two replaced sites inChatView.swiftare the only ones. ✅ - ObservationBoundaryView fix validated. Since closures aren't
Equatable, the boundary view's closure was being treated as changed every time the outer body reconstructed — negating LUM-1273's boundary. This fix prevents outer body reconstruction during streaming, restoring the boundary's intended effect. ✅
AGENTS.md addition is accurate and precisely worded. The note that .onChange(of:) with a collection of: expression also tracks the outer body is a non-obvious gotcha worth documenting.
No anti-patterns. No layout/LazyVStack concerns. Devin reviewed and found no issues.
Adds
isPaginatedEmpty— a cached boolean onChatPaginationState— soChatView's outer body observes a low-frequency boolean instead of the high-frequencypaginatedVisibleMessagesarray, eliminating the main-thread stall during streaming that triggered Sentry hang MACOS-RJ.Why needed: The outer
ChatView.bodyevaluated.onChange(of: shouldShowSkeleton, initial: true)which readpaginatedVisibleMessages.isEmpty. With@Observable, this tracked the full array property — any message mutation (every streamed token) invalidated the outer body. This also negated theObservationBoundaryViewadded in LUM-1273: since the outer body was invalidated, it reconstructed the boundary view struct, and SwiftUI re-evaluated it anyway (closures aren'tEquatable).Benefits: During streaming, the outer body is no longer invalidated on every message mutation. Only the
ObservationBoundaryViewbody re-evaluates (which is correct — the message list needs updating). This eliminates redundant reconstruction of the GeometryReader, ZStack, and full modifier chain (~12 modifiers including.onDrop,.onKeyPress,.overlay,.animation, multiple.onChange/.onReceive) that previously ran on every token during streaming.Safety:
isPaginatedEmptyis updated viadeferinrecomputePaginatedSuffix()— the same synchronous function that assignspaginatedVisibleMessages— preserving the consistency invariant from PR #24095 (no timing gap between the two). The write-only-on-change guard (if new != old) ensures observation fires only on actual empty↔non-empty transitions. Both properties live on the same@MainActor @Observableclass, updated in the same synchronous call.AGENTS.md update: Adds a "Cache derived booleans from high-frequency collections" guideline to
clients/AGENTS.md§ Performance, documenting the pattern to prevent recurrence.References:
access(keyPath:)/withMutation(keyPath:)mechanicsAlternatives NOT taken:
shouldShowSkeleton.onChangeinside theObservationBoundaryView— Can't attach modifiers inside a@ViewBuilderclosure without restructuring the entire body. The modifier semantically belongs on the outer view (it controls@State showSkeleton).paginatedVisibleMessagesitself (if new != old { prop = new }) —[ChatMessage]comparison is O(n), and during streaming the arrays ARE different (last message text changes on every token), so the guard would always fail. Net cost increase with zero benefit.ObservationBoundaryViewEquatable — It stores a() -> Contentclosure, which can't conform toEquatable.withObservationTrackingbridge — Overly complex machinery for what is fundamentally a "cache a derived bool" problem.Root cause analysis:
How did the code get into this state? — PR fix(chat): use paginatedVisibleMessages for empty/skeleton routing to fix blank chat on load #24095 correctly switched
isEmptyState/shouldShowSkeletonfrommessages.isEmptytopaginatedVisibleMessages.isEmptyto fix a blank-screen bug caused by a timing gap between two@Observableobjects connected via Combine. This was the right fix for consistency, but inadvertently coupled the outer body to a high-frequency property.What decisions led to it? — PR fix(macos): narrow ChatView.body observation scope to prevent window-focus hang (LUM-1273) #28686 (LUM-1273) added
ObservationBoundaryViewto isolatemainContentStack, but focused only on reads inside the boundary — missing thatshouldShowSkeleton(evaluated in.onChange(of:)outside the boundary) still read the array in the outer scope. The fix addressed the symptom (message list reads) without auditing all outer-scope reads.Were there warning signs? — Yes. The
.onChange(of: shouldShowSkeleton, initial: true)modifier visually appears to only "use" a boolean, but its implementation evaluates the computed property during every body pass to compare old/new values. This indirection made the observation dependency non-obvious during code review.What prevents recurrence? — The new AGENTS.md guideline ("Cache derived booleans from high-frequency collections") makes this pattern explicit. It specifically calls out
.onChange(of:)as a hidden observation registration site. Future developers/agents will see this before adding.isEmpty/.countchecks on collection properties in view bodies or modifiers.Systemic observation: When narrowing observation scope (e.g., adding
ObservationBoundaryView), audit ALL reads in the outer scope — not just the content inside the boundary. Modifiers like.onChange(of:),.overlay {}, and.animation(_, value:)evaluate their expressions during body construction and register observation dependencies in the caller's scope.