Window the top-level timeline on phones and evict far pages (C1 step 2) - #1905
Closed
SawyerHood wants to merge 2 commits into
Closed
Window the top-level timeline on phones and evict far pages (C1 step 2)#1905SawyerHood wants to merge 2 commits into
SawyerHood wants to merge 2 commits into
Conversation
SawyerHood
force-pushed
the
bb/mobile-perf/composer-draft-pushdown
branch
from
August 19, 2026 07:42
df70118 to
74407b8
Compare
SawyerHood
force-pushed
the
bb/mobile-perf/timeline-windowing
branch
from
August 19, 2026 07:42
fd63350 to
008ebcb
Compare
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
force-pushed
the
bb/mobile-perf/composer-draft-pushdown
branch
from
August 19, 2026 07:50
74407b8 to
cdf3f13
Compare
SawyerHood
force-pushed
the
bb/mobile-perf/timeline-windowing
branch
from
August 19, 2026 07:50
008ebcb to
16bfd08
Compare
SawyerHood
marked this pull request as ready for review
August 19, 2026 08:04
SawyerHood
marked this pull request as draft
August 19, 2026 15:09
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Every loaded timeline page stayed mounted.
TimelineRowsListrendered one wrapper per top-level row,prependOlderTimelineRowsconcatenated 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
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 (rangeCoversViewportwith half-overscan slack), id-based range/anchor resolution that survives prepends, and segment building (mounted entries + spacers sized to the rows they replace).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; correctsscrollTopin the same layout pass by keeping the first visible mounted row in place (idempotent under Chromium scroll anchoring, does the job on WebKit); snaps theAutoHeightContaineron 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 carrydata-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: optionalinitialManualExpansionOverride/onManualExpansionOverrideChangeseed props (state stays local).height-transition.tsx:useAutoHeightSnap()context so the window can snap the wrapper after a swap.timeline-row-containment.ts:estimateSkippedTimelineRowBlockSizePxshared by containment and windowing so spacer and skipped rows agree.How you verified
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.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 newreact-hooks/refswarnings in the hook for the deliberate controller reads, same category as existing timeline code). Vitest oversrc/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).bb/mobile-perf/composer-draft-pushdown(Push the composer draft subscription below ThreadDetailPromptArea (D1) #1904).bb/mobile-perf/persisted-query-cache(Persist an allowlisted query cache slice to IndexedDB behind an experiment (J2) #1906).