eleven / messenger Public

ios: hot thread opens land settled in the first frame (#1216)

* ios: hot thread opens land settled in the first frame — no pin dance, no held reveal

A parked or store-hydrated model lays out BEFORE the feed appears, so
defaultScrollAnchor(.bottom) has already landed the first frame at the
bottom against real row heights. The old settle() still ran the
estimated-height scrollTo dance and held the feed at opacity 0 until its
timers fired — timers queued behind the open's own layout churn, which
is where the felt 1-3s opens lived (#1210) and where the stranded
above-the-fold reveals came from (#1209). Hot opens now reveal
immediately with no scrollTo; cold loads keep the pin phase (their
content lands after first layout, behind the spinner), with the +0.35s
fallback now re-pinning unconditionally so a stale bottomVisible flag
can never reveal a stranded viewport.

feed.mount sharpens with it: begun at the feed's opening appear, ended
one turn after `settled` commits — it now measures tap → usable feed
(cold fetch + pin included) instead of the first render pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: feed.loadOlder span — the scroll-back stall's fold half (#1211)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: defer the opening inset re-pin past estimated layout; gate scroll-back on the landed beat

The inset re-pin fired DURING the opening layout pass — a scrollTo against
estimated row heights, which stranded the viewport beyond the content
(blank feed, messages above the fold) and whose transient top-row
materialisation now passed the settled gate and cascaded spurious
loadOlder prepends (the repeated jump-up). One-turn deferral lands the pin
on real layout; the landed gate restores the opening-transient protection
the settled hold used to provide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell acb46363e071efcc38ad128eef6f33a09c1f9268 parent 285526f
2 files changed, +81 −28
  • ios/LChat/Views/NativeChatScreen.swift +73 −28
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +8 −0
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index c916b8b..ff04366 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -484,14 +484,6 @@ struct NativeChatScreen: View {
.sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
.sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
.onAppear {
- // The mount cost — the "opening a thread hangs a little"
- // (#1210). feed.openThread only covers first loads; re-opens
- // attach a parked model and their whole cost is THIS mount.
- // Ending on the next main-queue turn brackets the first
- // render pass — an approximation, but one that made the 1–3s
- // opens visible at all.
- let mount = Perf.begin("feed.mount", slowMs: 300)
- DispatchQueue.main.async { mount.end("\(model.messages.count) messages") }
model.start()
LastOpenedChat.note(origin: instance.origin, thread: thread.id,
title: thread.title)
@@ -614,8 +606,15 @@ struct NativeChatScreen: View {
// to the bottom, stranding the photo they just viewed off-screen. Set when
// we open the viewer, it makes that one return-appear skip the pin.
@State private var suppressSettleOnReturn = false
-
- private func settle(_ proxy: ScrollViewProxy) {
+ // The open's "tap → usable feed" measurement (#1210): begun when the feed
+ // first appears, ended one turn AFTER `settled` flips so the reveal
+ // frame's layout is inside it — not the old next-runloop approximation,
+ // which timed the first render pass and called 1–3s opens "fast".
+ @State private var mountSpan: Perf.Span?
+
+ // `hot` = the model was ready BEFORE the feed's first layout (a parked or
+ // store-hydrated open — the overwhelmingly common case).
+ private func settle(_ proxy: ScrollViewProxy, hot: Bool = false) {
guard !model.messages.isEmpty else {
settled = true // empty chat — nothing to pin
return
@@ -655,7 +654,21 @@ struct NativeChatScreen: View {
return
}
}
- // The immediate jump runs against ESTIMATED row heights — the
+ // A hot open needs NO pin at all: defaultScrollAnchor(.bottom) already
+ // landed the first layout at the bottom against REAL row heights (the
+ // content was folded before layout ran), so there is nothing to
+ // correct and nothing to hide behind an opacity hold. Reveal in the
+ // first frame. The estimated-height scrollTo this skips was itself
+ // the #1209 stranded-viewport source, and the held reveal — its
+ // timers queued behind the open's layout churn — was the felt 1–3s
+ // open (#1210). The composer-inset re-pin (onChange(composerHeight))
+ // still covers the one genuinely late measurement.
+ if hot {
+ settled = true
+ return
+ }
+ // COLD loads only, from here down: content published after first
+ // layout. The immediate jump runs against ESTIMATED row heights — the
// LazyVStack hasn't materialised anything yet, and attachment-heavy
// threads (320pt image rows vs ~40pt text rows) estimate so far off
// that the computed offset can strand the viewport BEYOND the real
@@ -678,13 +691,14 @@ struct NativeChatScreen: View {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
if bottomVisible { finishBottomPin() }
}
- // Bounded fallback: the reveal must never deadlock. If the sentinel
- // still hasn't reported in, pin once more and show the feed anyway.
+ // Bounded fallback: the reveal must never deadlock. Pin once more
+ // UNCONDITIONALLY — bottomVisible can be stale-true here (the
+ // estimated pass flashed the sentinel through the viewport), and
+ // skipping the pin on that lie revealed the feed stranded above the
+ // fold (#1209's cold-open shape).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
guard !settled else { return }
- if !bottomVisible {
- proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
- }
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
finishBottomPin()
}
}
@@ -722,11 +736,14 @@ struct NativeChatScreen: View {
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity))
.onAppear {
- // `settled` gate: the opening pin-jump
- // transiently materialises top rows, and each
- // spurious page prepend churned layout so hard
- // scrolling felt dead for seconds after open.
- guard settled, i < 8, model.ready, !model.exhausted else { return }
+ // `landed` gate (the +0.4s opening beat): the
+ // opening layout transiently materialises top
+ // rows, and each spurious page prepend jumped
+ // the feed up a screenful and churned layout so
+ // hard scrolling felt dead for seconds. Hot
+ // opens are `settled` from the first frame, so
+ // that gate alone stopped protecting this.
+ guard settled, landed, i < 8, model.ready, !model.exhausted else { return }
Task { await model.loadOlder() }
}
}
@@ -875,8 +892,19 @@ struct NativeChatScreen: View {
// yanking someone who has scrolled up into history.
.onChange(of: composerHeight) { old, new in
guard new > old, model.ready else { return }
- if !settled {
- proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ // Hot opens are `settled` from the first frame, but the inset
+ // still resolves late — `landed` (the +0.4s opening beat)
+ // keeps the hard, unanimated pin for that window. DEFERRED a
+ // turn: this fires DURING the opening layout pass, when row
+ // heights are still estimates, and a scrollTo computed
+ // against those strands the viewport beyond the content — a
+ // blank feed with the messages above the fold. One turn later
+ // layout is real and the pin lands exactly (the same trick as
+ // the sentinel's re-pin).
+ if !settled || !landed {
+ DispatchQueue.main.async {
+ proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
+ }
} else if bottomVisible {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
@@ -884,19 +912,36 @@ struct NativeChatScreen: View {
}
}
.onAppear {
+ // The mount clock starts at the OPENING appear only — modal
+ // returns (Quick Look, the video viewer) re-fire onAppear on
+ // an already-settled feed and must not restart it.
+ if mountSpan == nil, !settled {
+ mountSpan = Perf.begin("feed.mount", slowMs: 500)
+ }
// A parked model is ready immediately — onChange(ready) never
- // fires on a revisit, so settle from here too. But skip it when
- // returning from Quick Look: re-pinning would strand the
- // just-viewed photo off-screen (see suppressSettleOnReturn).
+ // fires on a revisit, so settle from here too, on the HOT
+ // path: its content laid out before this appear, so the
+ // bottom anchor already landed it. But skip it when returning
+ // from Quick Look: re-pinning would strand the just-viewed
+ // photo off-screen (see suppressSettleOnReturn).
if suppressSettleOnReturn {
suppressSettleOnReturn = false
- } else if model.ready {
- settle(proxy)
+ } else if model.ready, !settled {
+ settle(proxy, hot: true)
}
// Arm the arrival animation only after the opening render has
// settled (parked models are ready immediately).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true }
}
+ // The mount span ends one turn after the reveal commits, so the
+ // revealed frame's layout is measured — "feed.mount" now honestly
+ // means tap → usable feed, cold fetch + pin phase included.
+ .onChange(of: settled) { _, isSettled in
+ guard isSettled, let span = mountSpan else { return }
+ mountSpan = nil
+ let count = model.messages.count
+ DispatchQueue.main.async { span.end("\(count) messages") }
+ }
// Foregrounding with this chat on screen: merge whatever the NSE
// filed while we were suspended straight from disk — the pushed
// message shows before the socket reconnects and re-pulls.
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index bad154b..524559d 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -877,9 +877,16 @@ public final class ChatFeedModel: ObservableObject {
Date().timeIntervalSince(lastOlderLoad) > 0.5,
let oldest = messages.first?.id else { return }
loadingOlder = true
+ // The scroll-back stall's fold half (#1211): fetch + decrypt + merge.
+ // The layout storm the publish triggers lands in the frames AFTER this
+ // span — the hitch recorder owns that side; adjacency in the ledger
+ // ties them together.
+ let span = Perf.begin("feed.loadOlder", slowMs: 1000)
+ var folded = 0
defer {
loadingOlder = false
lastOlderLoad = Date()
+ span.end("\(folded) rows")
}
let rows = await api.messagesBefore(thread: threadID, before: oldest)
guard !rows.isEmpty else {
@@ -887,6 +894,7 @@ public final class ChatFeedModel: ObservableObject {
status = "beginning of chat · \(messages.count) messages"
return
}
+ folded = rows.count
await foldPageDetached(rows)
status = "\(messages.count) messages"
}