Skip to content

perf(scroll): cut pin-bottom forced layout/repaint cost on WebKitGTK - #860

Merged
mremond merged 1 commit into
mainfrom
mr/gracious-khayyam-d619b8
Jul 6, 2026
Merged

perf(scroll): cut pin-bottom forced layout/repaint cost on WebKitGTK#860
mremond merged 1 commit into
mainfrom
mr/gracious-khayyam-d619b8

Conversation

@mremond

@mremond mremond commented Jul 6, 2026

Copy link
Copy Markdown
Member

Fixes the remaining Linux/WebKitGTK freezes in busy rooms: the bottom-pin loop forced a full layout every frame for 60 frames and a full-scroller repaint (overflow toggle) on every trigger — new message, typing toggle, reaction — multiplying WebKitGTK's expensive layout/paint (RenderCostProbe layoutPaint 189–359ms).

  • Pin loop exits early once geometry is stable for 8 frames.
  • The forced repaint only runs when the pin actually moved scrollTop (fluux:pin-repaint=always|off to A/B on-device).
  • Typing/reaction toggles defer to an already-running pin loop instead of restarting it.
  • New [PinLoopProbe] log line attributes each costly pin run (trigger, frames, flush/scroll/repaint ms) in fluux.log.

The bottom-pin re-assert loop was the dominant main-thread cost in busy
rooms on Linux (RenderCostProbe layoutPaint 189-359ms with react as low
as 2ms): every new message, typing toggle or reaction restarted a
60-frame loop of per-frame forced layouts, each write followed by a
full-scroller repaint (overflowY toggle).

- Convergence early-exit: the loop now stops after 8 consecutive stable
  frames instead of always burning its full budget.
- Gated repaint: the overflow-toggle repaint only runs when the pin
  actually moved scrollTop (the WebKit stale-paint bug is specific to
  programmatic scrolls); fluux:pin-repaint=always|off overrides for
  on-device A/B.
- Typing/reactions deferral: a toggle while a pin loop is active no
  longer restarts it - the running loop picks up the height change on
  its next frame.
- [PinLoopProbe] instrumentation: one rate-limited fluux.log line per
  costly run (trigger, frames, writes, flush/scroll/repaint ms) so the
  next on-device report attributes the cost directly; all reassert call
  sites now carry a trigger label.
@mremond mremond added this to the 0.17.1 milestone Jul 6, 2026
@mremond mremond added the linux Linux related issues. label Jul 6, 2026
@mremond
mremond merged commit 6eda663 into main Jul 6, 2026
3 checks passed
@mremond
mremond deleted the mr/gracious-khayyam-d619b8 branch July 6, 2026 16:01
mremond added a commit that referenced this pull request Jul 15, 2026
…KitGTK half-freeze) (#1012)

## Summary

Fixes the residual WebKitGTK "half-freeze" that testers still hit on
0.17.x — even though #860 (pin-loop convergence) shipped in 0.17.1.

**Root cause.** The pin-to-bottom `forceRepaint()` (an `overflowY`
toggle → forced reflow) is a *full scroller re-layout + repaint* on
WebKitGTK, ~50–150ms each (near-free on Chromium/WKWebView-macOS). #860
made each pin run *converge*, but never coalesced the **rate** of runs:
the `new-message` trigger is ungated, so every arriving message
supersedes the loop and fires a fresh synchronous repaint. A **burst**
of new content — live group chatter, a reaction/media storm, or a
reconnect flushing queued messages — therefore fires one forced
WebKitGTK repaint per arrival, saturating the main thread for up to
~1.5s.

This matches the field reports exactly: the freeze only ever reproduces
while **new content arrives** (messages, reactions, pictures), never on
scrolling or room-switching, and correlates with flaky/Tor connections
(a reconnect delivers a burst).

**Why it stayed unconfirmed.** `[PinLoopProbe]` is per-run with a 50ms
threshold + 5s cooldown, so a burst of ten 60ms repaints logs *one* line
and hides the other ~540ms — structurally blind to bursts.

## Fix

Generalizes the existing MAM-catchup repaint suppression to **live
bursts** (new pure module `pinRepaintBurst.ts`): while content-arrival
pins keep firing within `PIN_BURST_WINDOW_MS` (200ms), the intermediate
`forceRepaint`s are suppressed — the scroll position is still written
via `scrollToIndex`, so the layout stays correct; only the paint is
deferred — and the pin loop's convergence forces exactly **one**
trailing repaint. A burst of N repaints collapses to ~1–2. The first
arrival of a burst still paints immediately, so single sends stay
snappy. Burst state is a hook-level ref so the debt survives loop
supersede and is flushed by whichever run finally converges; reset on
user-scroll takeover and conversation switch.

Also adds a burst-aware `[PinBurstProbe] burst settled: …
suppressedRepaints=N` log line so an on-device log confirms the
mechanism was real and the coalescing engaged.

## Verification

- TDD red/green: `MessageList.pinBottomRepaint.test.tsx` → a burst of 8
messages produces 8 forced repaints without the fix, <8 with it; plus 8
unit tests for the tracker.
- All 50 `test:scroll` WebKit + Chromium scroll invariants pass
(send-stick, at-bottom-stick, reaction-stick, media-drift all intact).
- 653 conversation unit tests, full typecheck (SDK + app), and lint all
green.

WebKitGTK repaint cost is not reproducible on macOS/Chromium, so
on-device confirmation from the reporter (via the new `[PinBurstProbe]`
line) is the final check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linux Linux related issues.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant