Skip to content

fix(desktop): stop channel switches jittering and flashing - #6524

Open
Maxwellimus wants to merge 2 commits into
perf/switch-tracingfrom
fix/switch-visual-stability
Open

fix(desktop): stop channel switches jittering and flashing#6524
Maxwellimus wants to merge 2 commits into
perf/switch-tracingfrom
fix/switch-visual-stability

Conversation

@Maxwellimus

Copy link
Copy Markdown
Contributor

Based on perf/switch-tracing — retarget to main after it merges. (Shares the settle seam in ChannelScreen and the community-reset inventory.)

Two visible artifacts made switching feel clunky (diagnosed by frame-by-frame analysis of a 120fps capture):

  1. Thread-summary rows reflowed ~3px each a beat (~200ms) after paint — measured as a +6px/+3px/0 differential shift across the viewport in consecutive frames. The summary button was inline-flex, so it participated in its wrapper's line box via its baseline — which moves when the avatar's content resolves (Radix fallback delay, or the avatar image landing after profiles hydrate). The button is now a block-level flex row (wrapper height = padding + fixed button height regardless of avatar state: 36px constant, was 39→36) and the facepile renders its fallback immediately — the participant starts with no image URL, so the 200ms fallback delay only left a blank hole.
  2. The timeline skeleton flashed on switches. Two causes: (a) the deferred-snapshot gap — 1–2 frames per switch where the skeleton masked the previous channel's lagging rows — now renders plain background (new "blank" body surface; blocks the same behaviors as the skeleton, only the visual differs; cold loads keep the skeleton); (b) stale revisits — the loading latch reset per switch, so re-entering a channel past the messages staleTime held a skeleton over a fully cached timeline for the whole refetch round-trip. Channels that settled once per session now render cached rows stale-while-revalidate (session registry, reset via resetCommunityState()).

Both are pinned by red-first regression specs: thread-summary-stability.spec.ts (row geometry through delayed profile hydration; failed 39→36px pre-fix) and channel-revisit-no-skeleton.spec.ts (staleness forced by shifting Date.now() past the freshness window; skeleton must never mount over cached rows).

Measured impact (live community, switch traces)

metric before after
warm/rapid switch, click→settled paint (median) 260ms 148ms (−43%)
all switches, p90 994ms 682ms
stale-revisit paint full refetch RTT behind a skeleton immediate (cached rows)
summary-row reflow on hydration 3px per row, every cold entry none (spec-pinned)

Recreates #6457, which was accidentally squash-merged into its base branch perf/switch-tracing (since reverted there). Same content; still stacked on #6455.

@Maxwellimus
Maxwellimus requested a review from a team as a code owner August 21, 2026 23:08
@Maxwellimus
Maxwellimus requested a review from wesbillman August 21, 2026 23:08
@Maxwellimus
Maxwellimus force-pushed the fix/switch-visual-stability branch from ee63e04 to 1a1fe23 Compare August 22, 2026 00:04
@Maxwellimus
Maxwellimus force-pushed the fix/switch-visual-stability branch from 1a1fe23 to 8067857 Compare August 22, 2026 16:53
Entering a channel whose thread-summary facepiles hydrate after first
paint (profiles resolving late) visibly shifted the timeline: every row
above a summary slid ~3px per summary a beat after content painted.

The summary button was inline-flex, so it participated in its wrapper's
line box via its baseline — and that baseline moves when the avatar's
content resolves (Radix fallback text appearing after its delay, or the
avatar image landing). Make the button a block-level flex row so the
wrapper height is padding + button height regardless of avatar state,
and render the facepile fallback immediately (fallbackDelayMs=0): the
participant starts with no image URL, so the default 200ms delay only
left a blank hole where the initials belonged.

Regression spec pins the wrapper height from first paint through
hydration and the root row's position after the facepile renders, with
the users-batch response deliberately delayed past first paint.

Signed-off-by: Max Lampert <maxwell@squareup.com>
Two skeleton flashes made switching feel clunky:

- Every switch: the deferred timeline snapshot lags the live one for a
  frame or two, and that gap painted the full skeleton to mask the
  previous channel's rows — a visible skeleton flash on every switch.
  The gap is a render-pipeline artifact, not a loading state: it now
  renders plain background (new "blank" body surface). Blank blocks the
  same behaviors as the skeleton (autoscroll, jumps, unread pill,
  scrollback), only the visual differs. Cold loads keep the skeleton.

- Stale revisits: the loading latch reset on every switch, so re-entering
  a channel past the messages staleTime refetched on mount with the
  unsettled skeleton branch active — a skeleton over a fully cached
  timeline for the whole relay round-trip. Channels that settled once
  this session now render their cached rows stale-while-revalidate
  (session registry, reset via resetCommunityState like every other
  community-scoped singleton).

The revisit spec forces staleness by shifting Date.now past the 5-minute
window and asserts the skeleton never mounts over cached rows; the
switch-gap behavior is covered by the timelineSnapshot unit suite.

Signed-off-by: Max Lampert <maxwell@squareup.com>
@Maxwellimus
Maxwellimus force-pushed the fix/switch-visual-stability branch from 8067857 to aea889c Compare August 22, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant