Skip to content

Window large mobile timelines - #1384

Open
SawyerHood wants to merge 8 commits into
mainfrom
bb/mobile-timeline-virtualization
Open

Window large mobile timelines#1384
SawyerHood wants to merge 8 commits into
mainfrom
bb/mobile-timeline-virtualization

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace the absolute-position timeline virtualizer with in-flow row windows.
  • Keep measured placeholders in normal document flow.
  • Update each window row independently instead of rerendering the complete list.
  • Preserve the visible row when placeholder heights change.
  • Preserve search targets, unread targets, stored scroll anchors, row interaction state, and bottom follow behavior.

iOS Safari results

Tested an 85-row mobile timeline from a seeded thread with 1,368 event rows.

  • 620 scroll samples across both directions after the final correction
  • 17 to 41 complete rows mounted during the run
  • 0.125-pixel maximum visible-row drift across ten delayed height-settle checks
  • 0 potential WebKit hangs in the Time Profiler trace

Verification

  • pnpm exec turbo run lint typecheck --filter=@bb/app
  • 51 focused app tests
  • pnpm exec turbo run build --filter=@bb/app
  • iPhone 16 Pro Simulator with iOS 18.4 Safari

AGENT GENERATED: by GPT-5.6


Update: scroll-time realization (2026-08-12)

Two follow-up commits fix scroll defects that field testing found after the results above.

Skips and jitters during momentum scrolls. The scroll owner sets data-scrollbar-scrolling on every scroll event, so window updates during a scroll skipped the anchor compensation and trusted native scroll anchoring — which WebKit does not implement. A placeholder that realized above the viewport shifted visible content by the placeholder-vs-real height delta.

Fix: donor-compensated realization. While a scroll is active:

  • Derealizations apply immediately (they swap in their just-measured height, so they are height-neutral).
  • Rows whose top is at or below the viewport realize immediately (growth only pushes layout downward).
  • Rows above the viewport mount and measure in one synchronous commit, then other placeholders that also sit fully above the viewport shrink by the same delta in a second commit in the same task. The net height above the viewport stays zero: nothing visible moves, no scrollTop write happens, and momentum survives. Donors shrink topmost-first and self-correct when they later realize or derealize.
  • A row with no donor capacity (near the top of the timeline) reverts to its unchanged placeholder within the same task and realizes through an idle flush (250ms after the last scroll event) with the standard capture/flushSync/restore anchor compensation.

An intermediate approach that deferred all above-viewport realizations to scroll idle was rejected: iOS emits scroll events through the whole momentum tail, so rows stayed blank until the scroll fully stopped.

New unit tests cover scroll-time below-viewport realization, donor shrink across multiple placeholders, donor growth for shorter-than-placeholder content, and the no-donor revert + idle-flush path. Typecheck passes and the timeline and scroll-body suites pass (170 tests).

AGENT GENERATED: update by Claude Fable 5

🤖 Generated with Claude Code

@SawyerHood SawyerHood changed the title bb/mobile timeline virtualization Remove mobile timeline scroll jitter Aug 12, 2026
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

I am the SlopCop. I am now reviewing this pull request for security, code quality, performance, architecture, and end-to-end behavior.

[location.state],
);
const initialWindowCenterIndex = useMemo(() => {
if (searchTarget !== null) {

@SawyerHood SawyerHood Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Search state from another thread can override the saved scroll anchor.

This condition uses the search sequence before it checks the target thread. An overlapping sequence centers the wrong thread and skips its saved anchor.

The always-realized path and the scroll hook already check the thread ID. Apply the same check here. Add a mismatched-thread test with a saved anchor.

return;
}
const scrollElement = getScrollElement?.() ?? null;
if (scrollElement === null || scrollElement.clientHeight === 0) {

@SawyerHood SawyerHood Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A zero-height scroll root permanently disables window updates.

The effect returns when the root first has zero height. No dependency changes when a hidden or initial flex layout later gives it height.

The initial estimated window then stays mounted, and other placeholders stay empty during a scroll. Remove this height check, or start the observer after a resize event.

aria-hidden={isRealized ? undefined : true}
style={isRealized ? undefined : { height: placeholderHeight }}
>
{isRealized ? renderItem(item) : null}

@SawyerHood SawyerHood Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Window removal resets user-controlled row state.

This line removes the row subtree after the row leaves the margin. Several children store user state in the component. They include row expansion, long-message expansion, and tool-argument expansion.

A user can expand an old row, scroll away, and return to a collapsed row. Store the state above the window, or keep stateful rows mounted. Add an exit and return test.

@SawyerHood SawyerHood left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

ELI5: This change keeps only nearby mobile thread rows active. It uses empty boxes for other rows, which reduces browser work.

I found three issues.

  • A row loses its local user state after it leaves the window. An expanded row can return in a collapsed state.
  • A zero-height scroll root can prevent all later window updates. This can leave empty rows after the surface becomes visible.
  • Search state for another thread can override the correct saved scroll anchor.

I left inline comments at each affected line.

The security review found no issue. The change adds no network, authorization, storage, code execution, or HTML injection path.

The new window code repeats the design in SidebarWindowedItems.tsx. A shared keyed, in-flow window hook could reduce future behavior differences.

I tested a compact mobile route with a seeded 1,202-event thread. The route kept 25 of 73 top-level rows active and reached both ends without a page error.

The focused test passed all 34 tests. The Turbo app type check also passed.

@SawyerHood
SawyerHood force-pushed the bb/mobile-timeline-virtualization branch from 14056ad to 0c88e34 Compare August 12, 2026 07:12
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

I am SlopCop. I am reviewing this pull request for security, code quality, performance, architecture, and product behavior.

I started three parallel reviews. I will add one final review after the code and product checks finish.

@SawyerHood
SawyerHood force-pushed the bb/mobile-timeline-virtualization branch 2 times, most recently from e04cde8 to 00b7a7b Compare August 12, 2026 07:40
@SawyerHood
SawyerHood force-pushed the bb/mobile-timeline-virtualization branch from 00b7a7b to 31491a0 Compare August 12, 2026 07:41
SawyerHood and others added 3 commits August 12, 2026 07:43
During a scroll the windowing observer skipped its anchor compensation
and trusted native scroll anchoring, but WebKit has none. A placeholder
that realized above or over the viewport shifted visible content by the
placeholder-vs-real height delta, so the timeline skipped and jittered.

Now, while the scroll marker is set, only height-neutral updates apply
immediately: derealizations swap in their measured height, and
realizations fully below the viewport push layout downward only.
Realizations at or above the viewport wait in a pending set; when
scroll events stay silent past the idle delay, they flush through the
existing capture-anchor / flushSync / restore path, which can no longer
kill momentum because the scroll has stopped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Waiting for full scroll idle made rows blank for the whole momentum
tail: iOS emits scroll events through the entire deceleration, so every
event pushed the idle flush out and everything the user scrolled up
into stayed empty until the scroll fully stopped.

Rows above the viewport now realize the moment they enter the margin,
even mid-scroll. The realized height delta is balanced against
placeholder donors that also sit fully above the viewport, in a second
synchronous commit in the same task, so the net height above the
viewport stays zero: nothing visible moves, no scrollTop write happens,
and momentum survives. Donors shrink topmost-first and self-correct
when they later realize or derealize. A row with no donor capacity
(near the top of the timeline) reverts to its unchanged placeholder and
keeps the idle-flush fallback with scrollTop compensation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood SawyerHood changed the title Remove mobile timeline scroll jitter Window large mobile timelines Aug 12, 2026
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review (manual run)

I’m the Slop Cop for this manual run. I’m reviewing this PR now against the security, code quality, architecture, performance, and end-to-end guidelines. I will post one final comment after the parallel review and browser check finish.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review (manual run)

ELI5: This change keeps nearby mobile timeline rows active. It replaces distant rows with empty boxes, which reduces browser work while preserving scroll height.

I found five blockers at head f7d54c2.

  1. High — The list shows blank rows near the timeline start. The no-donor path removes an intersecting row and waits for scroll idle. See ThreadTimelineRows.tsx lines 2454–2480. My mobile check saw three to four visible placeholders near scrollTop 0.

  2. High — Late media growth can move visible content. The controller measures a row only after its first synchronous mount. See ThreadTimelineRows.tsx lines 2388–2410. A lazy image can grow later without donor compensation. See TimelineRowDetails.tsx lines 91–97.

  3. Medium — Window eviction loses the terminal-error expansion latch. The latch lives in row-local state. See ExpandableTimelineRow.tsx lines 101–108. Eviction removes that component at ThreadTimelineRows.tsx line 2062. A former frontier error can return collapsed.

  4. Medium — A window-mode change loses local row state. The windowed and normal paths use different React trees. See ThreadTimelineRows.tsx lines 2608–2674. I expanded a row on mobile. A desktop-width resize collapsed it, and the mobile layout kept it collapsed.

  5. Medium — Interaction pins can remove the window bound. Any click or focus pins a row for the list lifetime. See ThreadTimelineRows.tsx lines 2001–2060. Repeated interaction can keep every heavy row active.

The security review found no new security issue. This change adds no network, storage, authorization, code execution, or unsafe HTML path.

The performance review also found two non-blocking costs. Row updates rebuild the observer for all wrappers, and donor selection can approach quadratic work. The final donor commits also need a new iOS WebKit trace because they add synchronous work during active scroll.

The new code repeats the observer maps, wrapper registration, and placeholder design from SidebarWindowedItems.tsx. A shared in-flow window primitive would reduce drift. The timeline-specific momentum policy should remain separate.

I tested a 390×844 route with a seeded 1,368-event thread. It loaded 124 wrappers and kept 15–35 complete rows during 122 scroll samples. The route had no console or server errors.

The focused timeline file passed 38 tests. The Turbo app type check passed. All current GitHub checks passed.

- No-donor realizations no longer leave blank rows: instead of reverting
  to a placeholder and waiting for scroll idle, the row realizes
  immediately and a clamped scrollTop write absorbs whatever residual
  the donors could not. The idle-flush machinery is gone.
- Late content growth (lazy images, async rendering) in realized rows
  above the viewport is now compensated: a ResizeObserver diffs each
  realized wrapper against its last committed height and balances the
  delta into donors between layout and paint.
- Terminal auto-expansion ids accumulate at the timeline root, so a
  latched frontier-error row that windowed eviction unmounted re-expands
  when it remounts.
- Interaction pins are capped at 24 in recency order; the oldest pin
  releases and its row derealizes on its next window exit.
- The Intersection/Resize observers no longer recreate on every streamed
  row: callbacks read the current key order through a ref, so row
  updates stop re-firing initial entries for all wrappers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Addressed the SlopCop findings in f3bd288:

  1. Blank rows near the timeline start (High) — fixed. The no-donor path no longer reverts to a placeholder and waits for scroll idle. The row realizes immediately, donors absorb what they can, and a clamped scrollTop write absorbs the residual. A residual only occurs in the final viewport-heights of the timeline, where the scroll is about to hit the hard top anyway, so the momentum trade is acceptable there. The idle-flush machinery is deleted.

  2. Late media growth (High) — fixed. A ResizeObserver now diffs every realized wrapper against its last committed height. When a wrapper fully above the viewport grows or shrinks after mount (lazy image decode, async rendering), the delta balances into donor placeholders between layout and paint — the same invariant as mount-time compensation. Growth in or below the viewport stays uncompensated on purpose: a visible image expanding in place is expected content behavior.

  3. Terminal-error latch lost on eviction (Medium) — fixed. Terminal auto-expansion ids now accumulate at the timeline root and keep feeding the context, so an evicted latched row re-expands on remount. A manually collapsed row stays collapsed: collapsing is an interaction, which pins the row against eviction.

  4. Window-mode switch loses row state (Medium) — accepted, not fixed. Crossing the 768px boundary swaps the windowed and plain trees (and the AutoHeightContainer wrapper), which remounts the list. Unifying the trees would force the height-transition clipping behavior onto the windowed list. Losing transient expansion state on a device-class change is a trade we're keeping.

  5. Unbounded interaction pins (Medium) — fixed. Pins are capped at 24 in recency order. The oldest pin releases and its row derealizes on its next window exit.

Also took the performance note: the Intersection/Resize observers no longer recreate on every streamed row — callbacks read the current key order through a ref, so row updates stop re-firing initial entries for every wrapper.

Typecheck passes and the timeline + scroll-body suites pass (171 tests, including new coverage for the residual write, late-growth re-balancing, and the pin cap).

AGENT GENERATED: by Claude Fable 5

SawyerHood and others added 3 commits August 12, 2026 09:39
The react-hooks lint flags ref reads in an effect cleanup; the map ref
never changes identity, so a local capture is equivalent and quiets the
rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Placeholder estimates undershoot real row heights, so upward scrolling
drained the donor pool monotonically; once dry, every realization wrote
scrollTop and stopped WebKit momentum.

Two changes keep the pool solvent and the writes rare:
- Idle re-budgeting: track the running average of first-measured row
  heights, and once the scroll idles re-seed every never-realized
  placeholder to that average through the anchor-compensated path (a
  scrollTop write is free at idle). This refills drained donors and
  keeps the estimate budget tracking the timeline's real heights.
- Residual tolerance: a shortfall of 48px or less shifts content
  instead of writing scrollTop — a sub-threshold stutter reads better
  than a momentum stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SlopCop-driven changes traded the momentum-preserving revert
fallback for mid-scroll scrollTop writes and tolerated sub-threshold
shifts, and let ResizeObserver rebalance donors during active scrolls.
All three mutate what the user sees mid-gesture, which reads as
snapping.

Restore the invariant: while a scroll is active, the only geometry
change is the exact net-zero donor swap. Everything else waits for the
idle pass:
- An insolvent realization reverts to its unchanged placeholder and
  mounts at idle with anchor compensation (brief near-top blanks, made
  rare by re-budgeting).
- Late content growth above the viewport compensates with a direct
  scrollTop nudge at idle only; during a scroll just the baseline
  updates.
- The pending-realize flush and the placeholder re-budget share one
  idle pass and one anchor-compensated commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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