eleven / messenger Public

ios: fix intermittent chat unresponsiveness — nav dedup + drop dead per-frame frame tracking (#580) (#599)

* ios: make chat-list navigation push idempotent (#580)

A member reported that repeatedly tapping a chat row while the UI was
stalled opened the chat, and hitting back then surfaced a phantom second
copy of it. The taps had all registered — each ran the list's unguarded
`path.append(.thread(id))` — but a stalled main thread meant nothing
committed visually until later, and a NavigationStack path is an array
that holds duplicates, so the stack ended up with several identical
`.thread(id)` entries.

Guard the push: `ChatNavGuard.shouldPush` skips it when that chat is
already on top, collapsing queued taps to a single push (the same idiom
routeNotificationTap already uses). This makes navigation robust *to* a
main-thread stall; it does not remove the stall (layout-loop / gesture
fixes still to come).

The guard is a pure helper so it's deterministically unit-testable
without the SwiftUI/UIKit runtime the stall lives in — ChatNavGuardTests
documents the bug (queued taps duplicate) and pins the fix.

Also adds docs/threading-580-repro.md: a signal-based reproduction recipe
for the wider #580 symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ios+mac: stop the onChange(of: CGRect) layout churn (#580)

The image / video / YouTube cards tracked their `.global` frame with a
GeometryReader + onChange. That continuously observes a value that
changes on EVERY layout pass, and a chat open runs several layout passes
per displayed frame (scroll-settle pin, avatar spring, composer inset,
keyboard) — so the frame changes several times per frame and SwiftUI
flags "onChange(of: CGRect) action tried to update multiple times per
frame": real main-thread layout churn, a #580 stall suspect.

This is NOT a self-invalidation loop — an earlier attempt to move the
write off @State did not silence it (confirmed by breakpoint on the image
bubble's onChange). The trigger is observing a high-frequency value at
all. And that value is read ONLY at tap time and ONLY on macOS (the
lightbox zooms out from it; iOS ignores it — every onImageTap consumer
takes `_`).

So compile the tracker out on iOS and keep it on macOS, via a shared
`trackingFrame(into:)` modifier (LiveFrame holder stays a plain reference
so the macOS write never invalidates the view). iOS loses nothing and
sheds the churn; macOS behaviour (lightbox zoom origin) is unchanged.

Runtime behaviour the test suite can't cover — validate via the console
signal (docs/threading-580-repro.md): the warning should no longer appear
on iOS while scrolling / animating the keyboard in a media-heavy chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Dermot Daly 04e329fdc83343bcba5de0545720d7280e64433d parent 13bed61
8 files changed, +231 −22
  • .gitignore +2 −0
  • docs/threading-580-repro.md +72 −0
  • ios/LChat/Views/ThreadListView.swift +16 −1
  • ios/LChatCore/Sources/LChatCore/ChatNavGuard.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/LiveFrame.swift +42 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +10 −14
  • ios/LChatCore/Sources/LChatCore/YouTubeCard.swift +5 −7
  • ios/LChatCore/Tests/LChatCoreTests/ChatNavGuardTests.swift +59 −0
diff --git a/.gitignore b/.gitignore
index 5c585ea..c664576 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,5 @@ CLAUDE.local.md
# SwiftPM build dir (swift test in ios/LChatCore)
ios/LChatCore/.build/
+# Per-user Xcode state for the SwiftPM package (scheme mgmt, breakpoints, etc.)
+ios/LChatCore/.swiftpm/
diff --git a/docs/threading-580-repro.md b/docs/threading-580-repro.md
new file mode 100644
index 0000000..1660937
--- /dev/null
+++ b/docs/threading-580-repro.md
@@ -0,0 +1,72 @@
+# 🤖 Reproducing the iOS threading issues (#580)
+
+Working notes for validating the fixes on this branch. The user-visible hang is
+intermittent because it needs a main-thread stall to coincide with a tap — so
+instead of chasing the rare hang, reproduce the **mechanism signals** (console
+warnings), which fire far more reliably, and treat "signal gone" as the pass
+condition.
+
+## The signals to watch (device console — Xcode debug console or Console.app)
+
+| Signal | What it means |
+|---|---|
+| `onChange(of: CGRect) action tried to update multiple times per frame` | The `globalFrame` layout feedback loop in image/link/YouTube bubbles (`MessageBubble.swift`, `YouTubeCard.swift`, `ChatView.swift`). Main-thread layout churn. |
+| `System gesture gate timed out` | The system back/edge gesture waited on the feed's gesture stack (per-row swipe-to-reply + long-press + iOS-26 content-pop) and gave up. |
+| `Got a keyboard will change frame / will hide notification, but keyboard was not even present` | Spurious keyboard notifications — tied to the slow-to-focus symptom. |
+
+Filter Console.app on the app process with text `multiple times per frame` or
+`gesture gate`.
+
+## Setup that maximizes the odds
+
+- A space with **many chats** (warming starts 5 live feed-model sockets).
+- Open a chat that has **images and/or a link-preview or YouTube card** on
+ screen (those bubbles carry the `globalFrame` tracker that fuels the loop).
+- A **second device / the web client** generating churn into the space: flap
+ presence (foreground/background), type, send messages.
+- Use the **slowest device** available — main-thread stalls are amplified there.
+
+## Per-symptom recipe
+
+1. **Layout loop** — open the media chat; raise/dismiss the keyboard and scroll,
+ repeatedly. Expect the `onChange(of: CGRect)` warning to spam (near
+ deterministic).
+2. **Back delay** — with churn active, open the media chat and immediately tap
+ back; repeat ~20×. Watch for the visible delay + `System gesture gate`.
+3. **Phantom second copy** (the clearest one) — tap a chat row; if nothing
+ commits, tap it again a few times; when it finally opens, hit back. On the
+ buggy build "back" lands on a duplicate copy of the same chat.
+4. **Blank feed** — from the list, tap the media chat → immediately back →
+ immediately tap it again, ~10× fast. Feed opens empty; a tiny scroll redraws.
+5. **Slow focus** — open the media chat, immediately tap the composer. Delay
+ before the caret/keyboard; correlate with the CGRect warning.
+
+## Fixes on this branch
+
+- **Phantom second copy (#580, symptom 6):** navigation push is now idempotent —
+ `ChatNavGuard.shouldPush` skips a push when that chat is already on top, so
+ queued taps can't stack duplicate `.thread(id)` entries
+ (`ThreadListView.onSelect`). Deterministic regression test:
+ `ChatNavGuardTests`. This makes navigation robust *to* a stall; it does not
+ remove the stall.
+
+- **`onChange(of: CGRect)` layout churn (likely a root-cause contributor):** the
+ image / video / YouTube cards tracked their `.global` frame with a
+ `GeometryReader` + `onChange`. That's a *continuous observation of a value
+ that changes on every layout pass* — and a chat open runs several layout
+ passes per displayed frame (scroll-settle pin, avatar spring, composer inset,
+ keyboard), so the frame changes several times per frame and SwiftUI flags
+ `onChange(of: CGRect) action tried to update multiple times per frame`.
+ (Not a self-invalidation loop — moving the write off `@State` did NOT silence
+ it, confirmed by breakpoint at `MessageBubble` image bubble: the trigger is
+ observing a high-frequency value at all.) The frame is read **only at tap
+ time** and **only on macOS** (the lightbox zooms out from it; iOS ignores it —
+ every `onImageTap` consumer takes `_`), so the tracker is compiled out on iOS
+ via `trackingFrame(into:)` and kept on macOS. **Validate via the console
+ signal** — the warning should no longer appear while scrolling / animating the
+ keyboard in a media chat on iOS (runtime behaviour the test suite can't cover).
+
+_Still to land (tracked, not yet on this branch): apply `EdgeOnlyBackGesture`
+to `NativeChatScreen` on iOS 26; coalesce presence frames / skip no-op row
+reconfigures; make the feed reveal robust to a missed opening pin (blank
+feed)._
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index d1d4ab0..cb6bd51 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -51,6 +51,13 @@ struct ThreadListView: View {
store.instanceName.isEmpty ? store.instance.displayName : store.instanceName
}
+ // The chat currently on top of the stack (nil = this list is on top) —
+ // feeds the idempotent-push guard so queued taps can't stack duplicates.
+ private var topThreadID: String? {
+ if case let .thread(id)? = path.last { return id }
+ return nil
+ }
+
var body: some View {
// A UIKit UICollectionView list, not SwiftUI's List — see ThreadListCollection
// for why (stock UITableView row highlight that SwiftUI's List can't match).
@@ -58,7 +65,15 @@ struct ThreadListView: View {
if search.trimmingCharacters(in: .whitespaces).isEmpty {
ThreadListCollection(
threads: visibleThreads,
- onSelect: { path.append(.thread($0)) },
+ // Idempotent push (issue #580): while the main thread is
+ // stalled a member's taps queue, and each ran append — so
+ // "back" surfaced a phantom second copy of the chat. Skip
+ // the push when that chat is already on top.
+ onSelect: { id in
+ if ChatNavGuard.shouldPush(threadID: id, topThreadID: topThreadID) {
+ path.append(.thread(id))
+ }
+ },
onClose: { store.setHidden($0, true) },
onPin: { store.setPinned($0, !$0.pinned) },
onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) }
diff --git a/ios/LChatCore/Sources/LChatCore/ChatNavGuard.swift b/ios/LChatCore/Sources/LChatCore/ChatNavGuard.swift
new file mode 100644
index 0000000..2a9bb15
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ChatNavGuard.swift
@@ -0,0 +1,25 @@
+import Foundation
+
+// 🤖 Navigation push guard (issue #580, iOS threading).
+//
+// The chat list pushes a chat with `path.append(.thread(id))`. That append is
+// unguarded, and a `NavigationStack` path is an ARRAY — it holds duplicates
+// happily. When the main thread stalls (the layout-loop / socket-churn hangs
+// we're chasing), a member's taps on a row don't visually commit, so they tap
+// again… and every tap already ran `append`. When the stack finally commits it
+// holds two (or more) identical `.thread(id)` entries, and "back" surfaces a
+// phantom second copy of the same chat.
+//
+// The fix is idempotence: only push when the target chat isn't already on top
+// of the stack, collapsing queued taps to a single push. This mirrors the guard
+// the notification-tap path already uses (`path.last != .thread(id)` in
+// RootView.routeNotificationTap). Kept as a pure helper so it's unit-testable
+// without the SwiftUI/UIKit runtime the bug actually lives in.
+public enum ChatNavGuard {
+ /// Whether tapping into `threadID` should push a new chat screen, given the
+ /// thread currently on top of the navigation stack (`nil` = the top isn't a
+ /// chat, e.g. the chat list itself).
+ public static func shouldPush(threadID: String, topThreadID: String?) -> Bool {
+ topThreadID != threadID
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LiveFrame.swift b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift
new file mode 100644
index 0000000..924b9f2
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+// 🤖 A card's live `.global` frame, for the Mac lightbox zoom-out (issue #580).
+//
+// The image / video / YouTube cards need the thumbnail's window frame ONLY at
+// tap time, and ONLY on the Mac (the lightbox zooms out from it — iOS ignores
+// the value entirely: every `onImageTap` consumer takes `_`).
+//
+// The frame was tracked with a GeometryReader + `onChange(of:)`. That's a
+// continuous observation of a value that changes on EVERY layout pass — and a
+// chat open runs several layout passes per displayed frame (scroll-settle pin,
+// avatar spring, composer inset, keyboard), so the frame changes several times
+// per frame and SwiftUI flags "onChange(of: CGRect) action tried to update
+// multiple times per frame": real main-thread layout churn (a #580 stall
+// suspect). Moving the write off @State was NOT enough — the loop wasn't
+// self-invalidation, it's that we were observing a high-frequency value at all.
+//
+// So the tracker is compiled out on iOS (the value is dead there) via
+// `trackingFrame(into:)`, and kept on macOS where the lightbox needs it. The
+// holder stays a plain reference so the write never invalidates the view.
+// Only ever touched on the main actor (SwiftUI view code), hence @MainActor.
+@MainActor
+final class LiveFrame {
+ var rect: CGRect = .zero
+}
+
+extension View {
+ /// Keep `holder.rect` current with this view's `.global` frame — macOS only
+ /// (the only platform that reads it; see LiveFrame). A no-op on iOS, which
+ /// is what removes the layout-churn `onChange(of: CGRect)` there.
+ func trackingFrame(into holder: LiveFrame) -> some View {
+ #if os(macOS)
+ background(GeometryReader { g in
+ Color.clear
+ .onAppear { holder.rect = g.frame(in: .global) }
+ .onChange(of: g.frame(in: .global)) { _, f in holder.rect = f }
+ })
+ #else
+ self
+ #endif
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 0088f04..c20d6b8 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -728,7 +728,9 @@ public struct MessageBubble: View {
@Environment(\.colorScheme) private var scheme
@State private var poster: PlatformImage?
// The card's live window frame — the video lightbox zooms out from here.
- @State private var globalFrame = CGRect.zero
+ // A non-observed holder, not @State: reactively storing it looped layout
+ // (see LiveFrame / #580). Read at tap time only.
+ @State private var liveFrame = LiveFrame()
var body: some View {
let box = displaySize()
@@ -778,13 +780,9 @@ public struct MessageBubble: View {
}
}
.frame(width: box.width, height: box.height)
- .background(GeometryReader { g in
- Color.clear
- .onAppear { globalFrame = g.frame(in: .global) }
- .onChange(of: g.frame(in: .global)) { _, f in globalFrame = f }
- })
+ .trackingFrame(into: liveFrame)
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- .onTapGesture { onTap?(attachment, globalFrame) }
+ .onTapGesture { onTap?(attachment, liveFrame.rect) }
.task(id: attachment.id) {
guard poster == nil, let posterSource else { return }
poster = await BlobImageLoader.shared.image(posterSource,
@@ -816,7 +814,9 @@ public struct MessageBubble: View {
@State private var image: PlatformImage?
@State private var blur: PlatformImage?
// The thumbnail's live window frame — the lightbox zooms out from here.
- @State private var globalFrame = CGRect.zero
+ // A non-observed holder, not @State: reactively storing it looped layout
+ // (see LiveFrame / #580). Read at tap time only.
+ @State private var liveFrame = LiveFrame()
// Animated GIFs only: reduced-motion users get them paused at first.
@State private var playing = !gifsStartPaused()
@@ -848,18 +848,14 @@ public struct MessageBubble: View {
}
}
.frame(width: box.width, height: box.height)
- .background(GeometryReader { g in
- Color.clear
- .onAppear { globalFrame = g.frame(in: .global) }
- .onChange(of: g.frame(in: .global)) { _, f in globalFrame = f }
- })
+ .trackingFrame(into: liveFrame)
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.onTapGesture {
guard let image else { return }
// An animated GIF claims the click for play/pause (as on the
// web); everything else keeps the zoom-in lightbox click.
if isAnimatedGIF(image) { playing.toggle() }
- else { onTap?(attachment, image, globalFrame) }
+ else { onTap?(attachment, image, liveFrame.rect) }
}
.task(id: attachment.id) {
guard image == nil, let source else { return }
diff --git a/ios/LChatCore/Sources/LChatCore/YouTubeCard.swift b/ios/LChatCore/Sources/LChatCore/YouTubeCard.swift
index 09eebdc..12cfca7 100644
--- a/ios/LChatCore/Sources/LChatCore/YouTubeCard.swift
+++ b/ios/LChatCore/Sources/LChatCore/YouTubeCard.swift
@@ -15,7 +15,9 @@ public struct YouTubeCard: View {
public let onPlay: ((String, Int, CGRect) -> Void)?
@State private var playing = false
- @State private var globalFrame = CGRect.zero
+ // Non-observed holder, not @State: reactively storing the frame looped
+ // layout (see LiveFrame / #580). Read at tap time only.
+ @State private var liveFrame = LiveFrame()
public init(id: String, start: Int, onPlay: ((String, Int, CGRect) -> Void)? = nil) {
self.id = id
@@ -35,7 +37,7 @@ public struct YouTubeCard: View {
} else {
Button {
if let onPlay {
- onPlay(id, start, globalFrame)
+ onPlay(id, start, liveFrame.rect)
} else {
playing = true
}
@@ -60,11 +62,7 @@ public struct YouTubeCard: View {
}
}
.frame(width: width, height: height)
- .background(GeometryReader { g in
- Color.clear
- .onAppear { globalFrame = g.frame(in: .global) }
- .onChange(of: g.frame(in: .global)) { _, f in globalFrame = f }
- })
+ .trackingFrame(into: liveFrame)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.task(id: id) {
if thumb == nil { thumb = await YouTubeThumbCache.shared.image(for: id) }
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ChatNavGuardTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ChatNavGuardTests.swift
new file mode 100644
index 0000000..e087b02
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ChatNavGuardTests.swift
@@ -0,0 +1,59 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Regression guard for issue #580 (iOS threading). A stalled main thread let
+// a member's repeated taps on a chat row queue; each ran the list's
+// `path.append(.thread(id))`, so the NavigationStack ended up holding duplicate
+// copies of one chat and "back" surfaced a phantom second copy. The push must
+// be idempotent while that chat is already on top.
+//
+// This can't reproduce the underlying STALL (that lives in the SwiftUI layout
+// engine / gesture system), but it pins the state-machine invariant the stall
+// exposed: N taps on the same chat → exactly one push.
+final class ChatNavGuardTests: XCTestCase {
+
+ // Model the list's onSelect reducer over a stack of pushed thread ids
+ // (empty = the chat list on top). Mirrors ThreadListView's push.
+ private func applyTaps(_ taps: [String], guarded: Bool) -> [String] {
+ var stack: [String] = []
+ for id in taps {
+ if !guarded || ChatNavGuard.shouldPush(threadID: id, topThreadID: stack.last) {
+ stack.append(id)
+ }
+ }
+ return stack
+ }
+
+ // The bug, reproduced: four queued taps on ONE chat push four copies when
+ // unguarded — exactly the "back went to a second copy" report.
+ func testQueuedTapsDuplicateWithoutGuard() {
+ XCTAssertEqual(applyTaps(["T1", "T1", "T1", "T1"], guarded: false),
+ ["T1", "T1", "T1", "T1"])
+ }
+
+ // The fix: the same queued taps collapse to a single push.
+ func testQueuedTapsCollapseWithGuard() {
+ XCTAssertEqual(applyTaps(["T1", "T1", "T1", "T1"], guarded: true), ["T1"])
+ }
+
+ // Guarding must not block legitimate navigation between DIFFERENT chats.
+ func testDistinctChatsStillPush() {
+ XCTAssertEqual(applyTaps(["T1", "T2", "T3"], guarded: true), ["T1", "T2", "T3"])
+ }
+
+ // Re-opening a chat you just left is legitimate — it isn't on top anymore.
+ func testReopenAfterLeavingPushes() {
+ var stack = ["T1"]
+ stack.removeLast() // back to the list
+ if ChatNavGuard.shouldPush(threadID: "T1", topThreadID: stack.last) {
+ stack.append("T1")
+ }
+ XCTAssertEqual(stack, ["T1"])
+ }
+
+ func testShouldPushPredicate() {
+ XCTAssertTrue(ChatNavGuard.shouldPush(threadID: "T1", topThreadID: nil)) // from the list
+ XCTAssertTrue(ChatNavGuard.shouldPush(threadID: "T2", topThreadID: "T1")) // switching chats
+ XCTAssertFalse(ChatNavGuard.shouldPush(threadID: "T1", topThreadID: "T1")) // already on top
+ }
+}