Skip to content

Window the top-level timeline on phones and evict far pages (C1 step 2) - #1905

Closed
SawyerHood wants to merge 2 commits into
bb/mobile-perf/composer-draft-pushdownfrom
bb/mobile-perf/timeline-windowing
Closed

Window the top-level timeline on phones and evict far pages (C1 step 2)#1905
SawyerHood wants to merge 2 commits into
bb/mobile-perf/composer-draft-pushdownfrom
bb/mobile-perf/timeline-windowing

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

Every loaded timeline page stayed mounted. TimelineRowsList rendered one wrapper per top-level row, prependOlderTimelineRows concatenated pages without a cap, and auto-load pulled the next page 600px before the top. On a phone, reading back through a long thread left hundreds of markdown bodies and action bars in the DOM. Each style/layout pass (keyboard, orientation, streaming growth) and the memory footprint grew with how far the user had scrolled. Step 1 (content-visibility: auto, already on this branch) skips layout and paint of off-screen rows; the DOM itself was still unbounded.

What changed

  • New timeline-windowing.ts (pure geometry): entry layout with measured heights or per-kind estimates plus the flex gap, binary search for the entry at an offset, range = viewport +/- overscan (2 viewports, >= 1600px), hysteresis (rangeCoversViewport with half-overscan slack), id-based range/anchor resolution that survives prepends, and segment building (mounted entries + spacers sized to the rows they replace).
  • New useTimelineWindow.ts: samples the bottom-anchored scroll body on rAF-throttled scroll and on scroll-area resize; measures mounted wrappers with one ResizeObserver; keeps a viewport anchor by row id (null = bottom); re-ranges only when the viewport nears an edge; corrects scrollTop in the same layout pass by keeping the first visible mounted row in place (idempotent under Chromium scroll anchoring, does the job on WebKit); snaps the AutoHeightContainer on a swap; pins the live turn, unread divider, search-target ancestors and the focused row; skips swaps while a text selection is inside the list; seeds the first window from the saved per-thread scroll anchor; renders everything until sampled when windowing switches on later; re-anchors from the DOM when the anchored row is replaced.
  • ThreadTimelineRows.tsx: the top-level list renders windowed segments (spacers carry data-timeline-row-spacer, overflow-anchor: none) when compact and inside a scroll body; nested lists, desktop and surfaces without a scroll body render exactly as before. Unread divider gets a measured wrapper. Manual expand/collapse choices are remembered per row for the timeline instance so evicted rows re-mount in the state the user left them.
  • ExpandableTimelineRow: optional initialManualExpansionOverride / onManualExpansionOverrideChange seed props (state stays local).
  • height-transition.tsx: useAutoHeightSnap() context so the window can snap the wrapper after a swap.
  • timeline-row-containment.ts: estimateSkippedTimelineRowBlockSizePx shared by containment and windowing so spacer and skipped rows agree.

How you verified

  • New timeline-windowing.test.ts (13 tests): layout with gaps and measured heights, offset lookup, range with overscan and clamping, coverage/hysteresis, anchor resolution across a prepend, id round-trips, spacer heights add up to the un-windowed list height.
  • New ThreadTimelineRows.windowing.test.tsx (14 tests, jsdom with a simulated flex layout, ResizeObserver and rAF): all rows render when not compact or without a scroll body; initial window is the trailing block plus one estimated spacer; scrolling to the top mounts the head, evicts the middle and keeps the last row; no re-render inside the slack; measured heights size the spacer and rows re-mount in place; scrollTop is corrected when re-mounted rows are taller than their spacer share; a prepended page keeps the same rows mounted and grows the spacer by exactly the page's estimate; the anchored row being replaced keeps the window and re-anchors instead of jumping; running turn rows stay mounted far below; the search target's top-level row stays mounted; the saved scroll anchor seeds the first window; windowing that switches on after mount does not evict the current view; the unread divider is wrapped and pinned; a manually expanded row is still expanded after eviction and re-mount.
  • pnpm exec turbo run typecheck --filter=@bb/app: pass. pnpm exec turbo run lint --filter=@bb/app: 0 errors (6 new react-hooks/refs warnings in the hook for the deliberate controller reads, same category as existing timeline code). Vitest over src/components/thread, src/components/ui, src/components/plugin, src/views: 126 files / 1086 tests pass, including every existing timeline, scroll-body and containment test.

Fixes: part of the mobile / iOS Safari performance program (verified sweep report in the bb thread; no single issue).

Stack context

Layer 26 of 28 in the bb/mobile-perf/* stack (bottom → top: quick wins first, big rocks last).

  • Prerequisite (layer below): bb/mobile-perf/composer-draft-pushdown (Push the composer draft subscription below ThreadDetailPromptArea (D1) #1904).
  • Next layer: bb/mobile-perf/persisted-query-cache (Persist an allowlisted query cache slice to IndexedDB behind an experiment (J2) #1906).
  • Audit findings addressed: see title IDs. Review: approved-with-fixes; 1 review fix commit(s).
  • Reviewer notes / follow-ups: Not fixed (timing-dependent, self-healing): when a thread with a saved non-bottom scroll anchor mounts with rows arriving in a later commit than the scroll body (cache miss after g | Pre-existing/inherited from C1 step 1, noted by the implementer: rows re-mounted above the viewport carry the content-visibility estimate until realized, so WebKit (no scroll ancho | Design trade-off, not a bug: on compact viewports AutoHeightContainer is snapped on every window swap, so the timeline height glide is lost on phones (aligned with C2 direction).
  • Wire/contract: None. No server/daemon contract, WebSocket message, host RPC, route, CLI flag or config knob changed; HOST_DAEMON_PROTOCOL_VERSION untouched. Only apps/app client code.

AGENT GENERATED: by Claude Opus 5

@SawyerHood SawyerHood changed the title bb/mobile perf/timeline windowing Window the top-level timeline on phones and evict far pages (C1 step 2) Aug 19, 2026
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/composer-draft-pushdown branch from df70118 to 74407b8 Compare August 19, 2026 07:42
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/timeline-windowing branch from fd63350 to 008ebcb Compare August 19, 2026 07:42
SawyerHood and others added 2 commits August 19, 2026 07:50
Every loaded timeline page stayed mounted: `TimelineRowsList` mapped each
row to a wrapper, `prependOlderTimelineRows` concatenated pages without a
cap, and auto-load pulled the next page 600px before the top. On a phone,
reading back through a long thread left hundreds of markdown bodies and
action bars in the DOM, so every style/layout pass (keyboard, orientation,
streaming growth) and the memory footprint grew with how far the user had
scrolled. Step 1 (`content-visibility: auto`) skips their layout and paint;
this step bounds the DOM itself.

The top-level list is now windowed on compact viewports inside a
bottom-anchored scroll body (the same gate as row containment). Rows around
the viewport stay mounted; every maximal run of far rows becomes one spacer
sized from the rows' last measured heights (one ResizeObserver over the
mounted wrappers) or, for rows that never mounted, from the same estimate
`content-visibility` uses, so swapping a spacer for skipped rows is
height-neutral. The window keeps two viewports (>= 1600px) of overscan on
each side and only re-ranges once the viewport is within half of that of an
edge, so scrolling extends the near side and evicts the far side as a
by-product; unrelated re-renders (streaming) reuse the committed range.
Rows above the viewport that re-mount taller than their spacer share are
compensated in the same layout pass by moving scrollTop with the first
visible mounted row (idempotent under Chromium's native scroll anchoring,
does the whole job on WebKit, which has none), and the auto-height wrapper
is snapped so the swap does not ease.

Preserved on purpose:
- The viewport is anchored to a row id, so a prepended older page keeps the
  same rows mounted and lands as a spacer above; the scroll body's prepend
  restore then sees the spacer's height as before.
- The last row (and the running turn's top-level rows) is always mounted, so
  the bottom sentinel and the streaming row keep their state.
- The unread divider is always mounted (it scrolls itself into view once) and
  the search target's ancestors are pinned, so the reveal finds them.
- The saved per-thread scroll anchor seeds the first window, so the mount
  restore finds its row; a row that holds focus, or a live text selection,
  is never swapped away.
- The user's manual expand/collapse choices are remembered per row for the
  timeline's lifetime, so a turn opened far up does not come back collapsed.
- Desktop, nested lists and surfaces without a scroll body render exactly as
  before (no windowing, same DOM).

Windowing that switches on later (viewport crossing the compact breakpoint)
renders everything until the first sample reads the real viewport, and a
window whose anchored row was replaced keeps what is mounted and re-anchors
from the DOM instead of jumping to the bottom.

Co-Authored-By: Claude <noreply@anthropic.com>
A rows-driven commit (streaming rows appended, a turn summarised) recomputed
the mounted range from the state's viewport anchor, which is only as fresh as
the last sample that changed the window: a scroll inside the slack updates
nothing, so a user who scrolled up a little from the bottom still had a
"bottom" anchor, and once the live turn grew by more than the overscan the
range re-centred on the new bottom and evicted the rows in the viewport into
a spacer (blank screen until the next scroll).

Only a render driven by a fresh sample now moves the range from the state's
anchor. A rows-driven commit that finds the committed range no longer
covering (or its edge rows gone) keeps what is mounted and forces a sample
after commit, which re-anchors from the real scroll position and lands the
new range with its scrollTop correction. Adds a windowing test for the
stale-bottom-anchor case (fails before, passes after).

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/composer-draft-pushdown branch from 74407b8 to cdf3f13 Compare August 19, 2026 07:50
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/timeline-windowing branch from 008ebcb to 16bfd08 Compare August 19, 2026 07:50
@SawyerHood
SawyerHood marked this pull request as ready for review August 19, 2026 08:04
@SawyerHood
SawyerHood marked this pull request as draft August 19, 2026 15:09
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Closing: the maintainer decided to land only layers 1–22 of the mobile-perf stack (#1880#1901). The branch stays on the remote for reference; it can be reopened and rebased later if the remaining big rocks (sidebar-bootstrap patching, thread bootstrap include=, composer draft push-down, timeline windowing, persisted cache, service worker) are picked up again.

@SawyerHood SawyerHood closed this Aug 19, 2026
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