Skip to content

perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots#1452

Merged
wesbillman merged 3 commits into
mainfrom
brain/channel-switch-perf
Jul 2, 2026
Merged

perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots#1452
wesbillman merged 3 commits into
mainfrom
brain/channel-switch-perf

Conversation

@wesbillman

@wesbillman wesbillman commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What

Client-side channel-switching performance bundle — the goal is Slack/Discord-tier switching: never show a skeleton for a channel you've seen before, and make first-ever opens paint in one round trip.

Three commits, reviewable independently:

1. Don't block first paint on the row-floor top-up

The cold-load queryFn awaited up to 3 extra serial 200-event relay pages (pageOlderMessagesUntilRowFloor) whenever the first 60-event window rendered fewer than 30 top-level rows — which reply-heavy channels almost always trip. That made a cold open up to 4 serialized relay round trips before the skeleton dropped. Now the first window commits immediately and the top-up runs in the background (same self-merging pattern as the aux backfill), guarded by a per-channel in-flight map so a scroll-up fetch shares the running pass instead of duplicating REQs.

2. Persisted per-channel message snapshots + longer gcTime

Channels revisited after gcTime expiry or an app restart went fully cold. Now the newest 80-event slice per channel is persisted to localStorage (keyed per relay + channel, LRU-capped at 20 channels/relay, GC'd on workspace removal, pending optimistic events excluded) and fed to placeholderData — a cold revisit paints instantly and revalidates behind the paint. selectTimelineLoadingState gains one narrow pre-settle rule: placeholder rows drop the skeleton; live-sub-seeded partial data still holds it, so the intro-flash and older-fetch-spinner traps stay covered. gcTime raised 5→60 min.

Ghost-resurrection guard: a snapshot row deleted or edited while the app was closed never reappears in a history fetch (the relay soft-deletes), so its tombstone/edit is only reachable by #e over that row's id. Cold snapshot loads therefore widen the aux backfill to the snapshot-merged timeline (mergeHistoryOverSnapshot), not just the fresh fetch window — otherwise the deleted message would repaint from the snapshot and the post-settle snapshot rewrite would persist it forever. Warm refetches keep the narrow fresh-window backfill.

3. e2e contract update

The scroll-history cold-load test asserted the old contract (skeleton held while the paced top-up ran); it now asserts the new one (rows paint well inside the paced top-up window, skeleton gone once they do).

Dropped from this PR

An earlier revision included sidebar hover prefetch. Cut after review: with persisted snapshots, every previously-visited channel already paints instantly, so hover prefetch only helps the first-ever visit — and it paid for that with an uncancellable fetch+top-up pipeline per hovered row and unbounded cross-channel fan-out. If first-visit latency matters later, it should return as its own PR with a hover-intent delay.

Result

  • First-ever channel open: 1 relay round trip to paint (was up to 4).
  • Any previously-seen channel (including across app restarts): instant paint from snapshot, revalidate behind it.

Testing

  • pnpm typecheck, pnpm check, pnpm test — 1491 unit tests (16 new: messageSnapshot round-trip/eviction/bounds, loading-state placeholder rules, merged-timeline aux-backfill window).
  • Playwright: touched spec (scroll-history.spec.ts) run locally — 12 passed; the 2 failures (preserves user scroll…, does not teleport upward…) reproduce identically at the unmodified merge-base (c88799a), so they're pre-existing, not from this branch.

Follow-ups (separate PRs, discussed in #faster-channel-switching-and-loading)

Cold channel opens on reply-heavy channels awaited up to 3 extra serial
200-event relay pages (pageOlderMessagesUntilRowFloor) before the skeleton
dropped — up to 4 serialized round trips before first paint. Commit the
first 60-event window immediately and run the top-up in the background,
same pattern as the aux backfill (it self-merges into the cache key).

Guard against the now-possible overlap between the background top-up and
a scroll-up fetch with a per-channel in-flight pass map: concurrent
callers share the running pass instead of issuing duplicate REQs for the
same `until` window.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
wesbillman and others added 2 commits July 2, 2026 09:28
…shots

Channels revisited after gcTime expiry or an app restart went fully cold:
skeleton up for a relay round trip even though the timeline was known.
Persist the newest 80-event slice per channel to localStorage (same
stale-then-revalidate pattern as the sidebar's channelSnapshot, keyed per
relay + channel, LRU-capped at 20 channels per relay) and hand it to
placeholderData so a cold revisit paints immediately while the history
fetch revalidates behind it.

selectTimelineLoadingState gains one narrow pre-settle rule: placeholder
rows (cache or snapshot) drop the skeleton; live-sub-seeded partial data
still holds it, so the intro-flash and older-fetch-spinner traps stay
covered. gcTime raised 5min -> 60min so in-hour switches stay warm
in-memory. Snapshots are GC'd on workspace removal, skip pending
optimistic events, and skip identical rewrites.

Cold snapshot loads widen the `#e` aux backfill to the snapshot-merged
timeline (mergeHistoryOverSnapshot), not just the fresh fetch window.
A snapshot row deleted or edited while the app was closed never reappears
in a history fetch (the relay soft-deletes), so its tombstone/edit is only
reachable by `#e` over that row's id; backfilling only the fresh window
would resurrect the ghost and the post-settle snapshot rewrite would
persist it forever. Warm refetches keep the narrow fresh-window backfill.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The scroll-history cold-load test asserted the OLD contract: skeleton held
while the paced row-floor top-up ran. The top-up is now backgrounded, so
the test asserts the new one instead — first rows paint well inside the
paced top-up window (proving the queryFn no longer awaits it) and the
skeleton is gone once they do.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman force-pushed the brain/channel-switch-perf branch from da714b9 to 66ce8a6 Compare July 2, 2026 15:34
@wesbillman wesbillman changed the title perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots, hover prefetch perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots Jul 2, 2026
@wesbillman wesbillman merged commit deb3e6a into main Jul 2, 2026
25 checks passed
@wesbillman wesbillman deleted the brain/channel-switch-perf branch July 2, 2026 15:45
tlongwell-block pushed a commit that referenced this pull request Jul 2, 2026
* origin/main:
  fix(desktop): simplify workspace rail badges (#1462)
  perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots (#1452)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
tlongwell-block pushed a commit that referenced this pull request Jul 2, 2026
…tinct-on

* origin/main:
  fix(desktop): simplify workspace rail badges (#1462)
  perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots (#1452)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
wpfleger96 added a commit that referenced this pull request Jul 2, 2026
readiness.rs grew by 1 line (cargo fmt reformatted two long closures
inline after rebase). tauri.ts grew by 26 lines from PRs that landed
on main (#1452, #1416, #1449 and others) between our prior base and
this rebase tip (e42dae3).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Jul 2, 2026
…into HEAD

* origin/paul/nip-am-agent-turn-metrics:
  fix(profile): consolidate agent profile runtime metadata (#1451)
  fix(desktop): simplify workspace rail badges (#1462)
  perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots (#1452)
  perf(relay): bounded-concurrency multi-filter query execution (S2) (#1457)
  fix(desktop): classify timeline prepends so history loads don't bump unread (#1416)
  fix(desktop): quiet gate for workspace switches instead of boot splash (#1449)
  fix(read-path): reach complete threads, dense-second timelines, and all people in the GUI (#1418)
  E1+E3: reduce relay ingest/fan-out DB round trips; ack p99 −7–16%, fd p99 −6–28%, p999 tails −29–53% vs PR #1453 tip (#1454)
  perf(relay): defer post-commit dispatch and avoid verify clone (#1453)
  fix(relay): include git hook tools in runtime image (#1326)
  feat(chart): per-pod emptyDir git scratch when persistence disabled (multi-replica HA) (#1450)
  fix(relay): remove media bearer-token auth (#1444)
  fix(desktop): stop search shortcut from hijacking the sidebar (#1447)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Jul 2, 2026
readiness.rs grew by 1 line (cargo fmt reformatted two long closures
inline after rebase). tauri.ts grew by 26 lines from PRs that landed
on main (#1452, #1416, #1449 and others) between our prior base and
this rebase tip (e42dae3).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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