mac: feed scroll memory, second pass — capture during scroll, settle-gate every reveal (#923)
Paul's retest still jumped, for two real holes in #917: 1. The anchor was captured only on a 250ms trailing debounce, and teardown CANCELLED the pending capture — scroll up, click another chat within a beat, and no anchor was ever saved. Now the capture also runs throttled (~150ms) DURING scrolling plus once at teardown (no-op if the row frames already cleared), so the anchor is never more than a few frames stale. 2. Anchorless (at-bottom) reopens kept the parked instant-first-frame reveal, and with tall code-block bubbles the estimated-height pin corrections were a visible jump anyway. Every open now starts hidden until its position verifiably lands — bottom pin and anchor restore alike. A parked model settles in a verify round or two (~50-100ms of pane background), which reads as a fast render; a visible jump reads as broken. No jumps > instant. Also learned during diagnosis: a parallel session's install clobbered /Applications two minutes after the first fix landed (the known collision) — verify the INSTALLED dylib carries the change (strings | grep ScrollAnchor), not just the build product.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+30
−14
ios/LChatMac/NativeFeedView.swift+30 −14
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 6113823..7ad0827 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -95,6 +95,13 @@ struct NativeFeedView: View { |
| // wheel monitor fires per tick, and reading the row frames mid-fling |
| // is churn for nothing — capture once the scroll goes quiet. |
| var anchorDebounce: DispatchWorkItem? |
| + // Throttle stamp for the DURING-scroll captures. The trailing |
| + // debounce alone lost the race that mattered: scroll up, click |
| + // another chat within a beat — teardown cancelled the pending |
| + // capture and no anchor was ever saved (Paul's "state isn't being |
| + // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor |
| + // never more than a few frames stale, whatever happens next. |
| + var lastCaptureAt: TimeInterval = 0 |
| } |
| @StateObject private var frames = FrameStore() |
| @@ -115,12 +122,15 @@ struct NativeFeedView: View { |
| // The model arrives PARKED (NativeFeedModelCache) — folded messages and |
| // a live socket survive leaving the chat, so re-opening never flashes. |
| _model = StateObject(wrappedValue: model) |
| - // A parked model shows from the FIRST frame: history is folded and |
| - // .defaultScrollAnchor(.bottom) makes the first layout land at the |
| - // bottom, so there's nothing to hide — holding it behind the settle |
| - // verify (a ~50ms round-trip) blinked on every chat switch. Only a |
| - // cold model starts hidden; the pin still verifies either way. |
| - _settled = State(initialValue: model.ready) |
| + // EVERY open starts hidden until its position verifiably lands — |
| + // bottom pin or anchor restore alike. The first cut of this revealed |
| + // parked models from the first frame ("instant"), but rows |
| + // materialise with real heights after that frame, and with tall |
| + // bubbles (code blocks) the correction was a visible jump on every |
| + // switch — Paul's report, twice. A parked model settles in a verify |
| + // round or two (~50–100ms of pane background), which reads as a fast |
| + // render; a visible jump reads as broken. No jumps > instant. |
| + _settled = State(initialValue: false) |
| self.isGroup = isGroup |
| self.isActive = isActive |
| self.roster = roster |
| @@ -557,11 +567,13 @@ struct NativeFeedView: View { |
| // (the FeedWebCache rule); the cache's eviction stops them. |
| if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) } |
| swipeMonitor = nil |
| - // A capture racing the teardown would read half-cleared row |
| - // frames; the anchor is already current (captured on the scroll |
| - // that put the reader wherever they are). |
| + // One last capture at teardown, belt to the throttle's braces — |
| + // captureAnchor no-ops if the row frames have already been |
| + // cleared by the children's own teardown (order isn't |
| + // guaranteed), in which case the throttled capture stands. |
| frames.anchorDebounce?.cancel() |
| frames.anchorDebounce = nil |
| + captureAnchor() |
| if let key = instance?.origin { |
| PaneDropRouter.shared.unregister(key, token: dropToken) |
| } |
| @@ -766,11 +778,15 @@ struct NativeFeedView: View { |
| let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| guard paneFrame.rect.contains(point) else { return event } |
| - // Any scroll over this pane re-arms the position capture: once |
| - // the wheel goes quiet for a beat, remember where the reader is |
| - // (or that they're back at the bottom) for the next thread |
| - // switch. Debounced — reading row frames per tick mid-fling is |
| - // churn for nothing. |
| + // Any scroll over this pane captures the position: throttled |
| + // DURING the scroll (so a switch right after a fling still has a |
| + // near-current anchor — the trailing capture alone lost that |
| + // race), plus a trailing debounce for the exact resting spot. |
| + let now = ProcessInfo.processInfo.systemUptime |
| + if now - frames.lastCaptureAt > 0.15 { |
| + frames.lastCaptureAt = now |
| + captureAnchor() |
| + } |
| frames.anchorDebounce?.cancel() |
| let capture = DispatchWorkItem { captureAnchor() } |
| frames.anchorDebounce = capture |