← reports

#1301 · Thread timeline never unmounts history: 22k DOM nodes and 250-600ms long tasks after scrolling a large thread

Type: Bug (perf) Priority: not set on issue Effort: not set on issue perf open on GitHub investigated 2026-08-18 base commit 16ceb3a54 (main)

Verdict: REPRODUCED   Root-cause confidence: high

TL;DR

On a big thread the web app pages history in from the server just fine (anchor-based timeline?beforeAnchorSeq=…, ~10 pages of ~35–55 ms / 75–170 KB each on this machine). But every page the user scrolls into is prepended to a single in-memory array (loadedTimeline.rows) and ThreadTimelineRows renders that entire array as real React components and real DOM. Nothing is windowed, virtualised, or collapsed, so the DOM grows monotonically: on the seeded 9,001-event thread at a 390×844 viewport I measured 2,403 → 21,082 DOM nodes and 6,552 → 68,302 px of scroll height after reaching the top, exactly the shape the issue describes. Each prepended page costs one synchronous long task of ~280–470 ms (dev build, desktop CPU) made of mounting ~150 rows (~4,000 nodes) plus forced layout of the ever-larger document (readOverflowMeasurement in a useLayoutEffect is the largest attributed JS frame). The same accumulation happens at desktop widths (measured 21,182 nodes, long tasks up to 589 ms). Open PR #1384 (not linked to the issue) adds an in-flow windowed list for compact viewports and, with main merged in, bounds the DOM to ~1.7–2.2k nodes and long tasks to ~60–170 ms on the same repro; it does not touch desktop and has a real remount-on-threshold defect (details below).

Claims vs findings

Claim (issue)StatusEvidence
Opening the 9,001-event seeded thread starts at ~2.3k DOM nodes; initial page ~109 KB / ~50 msVerified2,403 nodes, 84 rows mounted at load; GET /timeline = 109,302 bytes in 58 ms (curl), 92 rows.
Scrolling to the top grows the DOM monotonically to ~22k nodes and ~75k px scroll height; nothing is unmountedVerified21,082 nodes / 820 mounted rows / 68,302 px after reaching the top; count never decreases across samples (browser-02, browser-03 logs). Numbers differ slightly from the issue because the seed fixture on this run is 8 threads / 34,536 events, not the 400k base + jumbo append.
Every older-page render is a 250–600 ms synchronous long task (desktop speed, dev build)VerifiedPerformanceObserver longtask: 10 long tasks, min 279 ms, median 343 ms, max 472 ms, total 3581 ms across the 10 older pages at 390 px width; 15 long tasks, min 59 ms, median 353 ms, max 589 ms, total 5213 ms at 1280 px width. Dev build (Vite, React dev runtime), Chromium headless.
Server-side pagination is already good (~10 KB / ~40 ms per page); the problem is purely client accumulationVerified (sizes larger than claimed)Walking all cursors: 11 pages, 943 rows, 6–54 ms each, but 72–167 KB per page (not ~10 KB) — the seeded assistant messages carry code blocks. Total 1.24 MB for the whole thread. Client accumulation is the mechanism (root cause below).
Multiply ~4× for phone CPUsUnverifiedNo phone hardware in this environment; a 4× CPU slowdown would put the measured 280–470 ms tasks in the 1–2 s range, plausible but not measured.
Comment: a 44,246-event / 131.7 MiB production conversation; latest-page projection reads 1.7–2.1 MiB and blocks the server 270–3,578 msUnverified / out of scopeServer-side read cost is tracked in #1131 / #1207 per the comment; on this fixture the latest page is 109 KB / 58 ms so nothing here contradicts or confirms the production numbers.
Suggested approach: window the list keyed on stable anchor ids; paging protocol needs no changesConsistent with codeRow ids are stable (thr_…:user-seed:8182 style anchors) and PR #1384 windows without protocol changes.

Environment

ItemValue
bb commit16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main, "Revert 'Stop clipping two-digit ordered list markers'")
OS / NodeLinux 7.0.0-29-generic (Ubuntu), Node v24.18.0, pnpm workspace
BrowserPlaywright Chromium (headless, via dev-browser CLI), viewport 390×844 (mobile) and 1280×900 (desktop). Vite dev build (React development runtime, so absolute times are inflated vs. production; the shape of the problem is unchanged).
Dev instancescripts/bb-dev-app current: App http://localhost:13028, Server http://localhost:21028, host daemon 127.0.0.1:29028, data dir ~/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-19-286d462cc7e4
Fixturepnpm seed:perf -- --projects 1 --threads 8 --events 60000 --seed 42 → project proj_mgvp7iamvh, largest thread thr_wfjb5qctw4 with 9,001 event rows (same size as the issue's thread)
ProvidersNot exercised; the seeded thread is archived and static, no agent turns were run.

Minimal reproduction

  1. Start the dev instance once so the seed can attach to the local host, stop it, seed, and restart:
    scripts/bb-dev-app current          # prints App/Server URLs + data dir
    pnpm dev:stop
    pnpm seed:perf -- --projects 1 --threads 8 --events 60000 --seed 42
    scripts/bb-dev-app current
    Seed output:
    ●  data dir /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-19-286d462cc7e4
      ●  host host_r6ri2fc2bi
      ○  prepared 1 projects, 4 environments, 8 threads
      ○  inserted 34536 event rows
      ○  inserted 1083 search segments and 835 prompt history rows
      ✓  seeded 1 projects, 8 threads, 34536 events in 1.0s
      ●  search segments: 1083, prompt history: 835
    Find the largest thread and its project:
    sqlite3 <data dir>/bb.db "select thread_id, count(*) c from events group by thread_id order by c desc limit 1;"
    # thr_wfjb5qctw4|9001
    sqlite3 <data dir>/bb.db "select project_id from threads where id='thr_wfjb5qctw4';"
    # proj_mgvp7iamvh
  2. (Optional, proves the server side is fine.) Walk every timeline page with 1301-walk-pages.mjsExpected and actual: 11 pages, all under 60 ms:
    page 1 rows=92 bytes=108920 ms=39 olderCursor={"anchorSeq":8182,"anchorId":"thr_wfjb5qctw4:user-seed:8182"}
    page 2 rows=85 bytes=106780 ms=6 olderCursor={"anchorSeq":7346,"anchorId":"thr_wfjb5qctw4:user-seed:7346"}
    page 3 rows=111 bytes=167317 ms=44 olderCursor={"anchorSeq":6474,"anchorId":"thr_wfjb5qctw4:user-seed:6474"}
    page 4 rows=73 bytes=81160 ms=54 olderCursor={"anchorSeq":5704,"anchorId":"thr_wfjb5qctw4:user-seed:5704"}
    page 5 rows=76 bytes=76485 ms=34 olderCursor={"anchorSeq":4927,"anchorId":"thr_wfjb5qctw4:user-seed:4927"}
    page 6 rows=78 bytes=91581 ms=39 olderCursor={"anchorSeq":4146,"anchorId":"thr_wfjb5qctw4:user-seed:4146"}
    page 7 rows=97 bytes=147632 ms=41 olderCursor={"anchorSeq":3208,"anchorId":"thr_wfjb5qctw4:user-seed:3208"}
    page 8 rows=92 bytes=136841 ms=43 olderCursor={"anchorSeq":2328,"anchorId":"thr_wfjb5qctw4:user-seed:2328"}
    page 9 rows=97 bytes=144864 ms=41 olderCursor={"anchorSeq":1438,"anchorId":"thr_wfjb5qctw4:user-seed:1438"}
    page 10 rows=85 bytes=107873 ms=36 olderCursor={"anchorSeq":609,"anchorId":"thr_wfjb5qctw4:user-seed:609"}
    page 11 rows=57 bytes=72615 ms=35 olderCursor=null
    TOTAL pages=11 rows=943 bytes=1242068
    
  3. Open http://localhost:13028/projects/proj_mgvp7iamvh/threads/thr_wfjb5qctw4 at a 390×844 viewport (script browser-01-open.js). Expected/actual: 84 rows, 2,403 DOM nodes, scroll height 6,552 px.
    Thread just opened at mobile width
    Before: the thread just after load, scrolled to the bottom (2,403 DOM nodes, 84 timeline rows mounted).
  4. Hold scroll-up until the first message while sampling DOM size and long tasks (script browser-03-wheel-scroll.js; a variant that sets scrollTop = 0 step by step is browser-02-scroll-history.js).
    Expected: mounted DOM stays roughly constant (only what is near the viewport), scrolling stays under 50 ms per frame.
    Actual (verbatim, wheel variant):
    {"label":"before","nodes":2403,"rows":84,"scrollHeight":6552,"scrollTop":5756,"longTasks":0}
    {"label":"burst-1","nodes":21082,"rows":820,"scrollHeight":68302,"scrollTop":0,"longTasks":10}
    /home/sawyer/.dev-browser/tmp/1301-mid-scroll.png
    {"label":"burst-2","nodes":21082,"rows":820,"scrollHeight":68302,"scrollTop":0,"longTasks":10}
    {"label":"burst-3","nodes":21082,"rows":820,"scrollHeight":68302,"scrollTop":0,"longTasks":10}
    LONGTASKS [{"start":11017,"dur":296},{"start":11332,"dur":286},{"start":11834,"dur":312},{"start":12173,"dur":343},{"start":12735,"dur":279},{"start":13306,"dur":400},{"start":13736,"dur":313},{"start":14293,"dur":462},{"start":14993,"dur":472},{"start":15755,"dur":418}]
    /home/sawyer/.dev-browser/tmp/1301-top-of-history.png
    
    Step-wise variant (one scrollTop=0 then 1.5 s wait per sample) shows the monotonic growth per page:
    {"label":"before","t":56526,"nodes":2403,"rows":84,"scrollHeight":6552,"scrollTop":5756,"longTasks":6,"loadOlderVisible":false}
    {"label":"page-1","t":58030,"nodes":6525,"rows":251,"scrollHeight":20043,"scrollTop":19247,"longTasks":8,"loadOlderVisible":false}
    {"label":"page-2","t":59535,"nodes":10047,"rows":388,"scrollHeight":31410,"scrollTop":30614,"longTasks":10,"loadOlderVisible":false}
    {"label":"page-3","t":61041,"nodes":13794,"rows":538,"scrollHeight":44265,"scrollTop":43469,"longTasks":12,"loadOlderVisible":false}
    {"label":"page-4","t":62547,"nodes":17881,"rows":694,"scrollHeight":57184,"scrollTop":56388,"longTasks":14,"loadOlderVisible":false}
    {"label":"page-5","t":64055,"nodes":21082,"rows":820,"scrollHeight":68303,"scrollTop":67507,"longTasks":16,"loadOlderVisible":false}
    {"label":"page-6","t":65562,"nodes":21082,"rows":820,"scrollHeight":68303,"scrollTop":0,"longTasks":16,"loadOlderVisible":false}
    {"label":"page-7","t":67070,"nodes":21082,"rows":820,"scrollHeight":68303,"scrollTop":0,"longTasks":16,"loadOlderVisible":false}
    {"label":"page-8","t":68576,"nodes":21082,"rows":820,"scrollHeight":68303,"scrollTop":0,"longTasks":16,"loadOlderVisible":false}
    LONGTASKS [{"start":8005,"dur":150},{"start":8159,"dur":94},{"start":9485,"dur":65},{"start":20294,"dur":173},{"start":20524,"dur":74},{"start":20599,"dur":59},{"start":56564,"dur":329},{"start":56907,"dur":205},{"start":58062,"dur":338},{"start":58418,"dur":243},{"start":59561,"dur":422},{"start":60015,"dur":313},{"start":61084,"dur":420},{"start":61536,"dur":314},{"start":62582,"dur":476},{"start":63095,"dur":385}]
    /home/sawyer/.dev-browser/tmp/1301-after-scroll-top.png
    
    Mid scroll
    The moment the bug shows: mid-history after several pages have been prepended. Visually fine, but the whole history below is still mounted.
    Top of history
    Top of history reached: 820 rows / 21,082 nodes / 68,302 px all mounted.
    After scrollTop=0 variant
    Step-wise variant end state (same numbers).
  5. CPU-profile one prepend via CDP (script browser-04-profile.js; raw .cpuprofile files in 1301/repro/). Top self-time frames for the first two older pages:
    === page1: rows 84 -> 251, sampled 1712ms ===
    665.0ms  (program) @ :-1
    435.7ms  (idle) @ :-1
    99.9ms  readOverflowMeasurement @ conversation-message-overflow.tsx:8
    90.8ms  (garbage collector) @ :-1
    68.5ms  exports.jsxDEV @ react_jsx-dev-runtime.js:190
    44.2ms  exports.jsx @ react_jsx-runtime.js:190
    21.2ms  run @ :-1
    13.0ms  exports.createElement @ react.js:590
    9.8ms  addObjectToProperties @ react-dom_client.js:2332
    9.8ms  getMaxScrollOffset @ bottom-anchored-scroll-body.tsx:48
    8.6ms  react_stack_bottom_frame @ react-dom_client.js:12863
    8.2ms  ReactElement @ react_jsx-dev-runtime.js:103
    6.9ms  addValueToProperties @ react-dom_client.js:2335
    6.9ms  ReactElement @ react_jsx-runtime.js:103
    6.2ms  addObjectDiffToProperties @ react-dom_client.js:2408
    5.7ms  setAttribute @ :-1
    5.4ms  now @ :-1
    5.4ms  createTask @ :-1
    5.0ms  ReactStrictModeWarnings.recordLegacyContextWarning @ react-dom_client.js:12834
    4.7ms  runWithFiberInDEV @ react-dom_client.js:846
    4.4ms  propagateParentContextChanges @ react-dom_client.js:3162
    3.8ms  ReactElement @ react.js:131
    3.8ms  syntaxExtension @ blank-line-CbzQQYtI.js:188
    3.6ms  logComponentRender @ react-dom_client.js:2460
    3.1ms  createElementNS @ :-1
    2.9ms  mergeProps @ @radix-ui_react-slot.js:56
    2.9ms  splice @ blank-line-CbzQQYtI.js:106
    2.9ms  validateProperty @ react-dom_client.js:1874
    2.9ms  getComponentNameFromType @ react_jsx-dev-runtime.js:14
    2.9ms  jsxDEVImpl @ react_jsx-dev-runtime.js:147
    --- by script ---
    665.0ms  (program))
    435.7ms  (idle))
    123.6ms  react-dom_client.js
    100.5ms  conversation-message-overflow.tsx
    90.8ms  (garbage collector))
    83.6ms  react_jsx-dev-runtime.js
    54.1ms  react_jsx-runtime.js
    21.2ms  run)
    20.9ms  react.js
    16.2ms  react-markdown.js
    10.5ms  bottom-anchored-scroll-body.tsx
    7.7ms  blank-line-CbzQQYtI.js
    5.7ms  setAttribute)
    5.4ms  now)
    5.4ms  createTask)
    === page2: rows 251 -> 388, sampled 1408ms ===
    422.3ms  (program) @ :-1
    422.3ms  (idle) @ :-1
    156.8ms  readOverflowMeasurement @ conversation-message-overflow.tsx:8
    69.6ms  exports.jsxDEV @ react_jsx-dev-runtime.js:190
    39.8ms  run @ :-1
    38.6ms  exports.jsx @ react_jsx-runtime.js:190
    19.8ms  (garbage collector) @ :-1
    13.8ms  addValueToProperties @ react-dom_client.js:2335
    10.2ms  addObjectToProperties @ react-dom_client.js:2332
    10.1ms  propagateParentContextChanges @ react-dom_client.js:3162
    10.0ms  exports.createElement @ react.js:590
    8.5ms  getMaxScrollOffset @ bottom-anchored-scroll-body.tsx:48
    7.8ms  addObjectDiffToProperties @ react-dom_client.js:2408
    7.6ms  now @ :-1
    7.2ms  ReactElement @ react_jsx-runtime.js:103
    6.4ms  createTask @ :-1
    5.9ms  setAttribute @ :-1
    5.5ms  react_stack_bottom_frame @ react-dom_client.js:12863
    4.6ms  ReactElement @ react_jsx-dev-runtime.js:103
    3.4ms  logComponentRender @ react-dom_client.js:2460
    3.3ms  runWithFiberInDEV @ react-dom_client.js:846
    3.3ms  ReactElement @ react.js:131
    3.1ms  visitParents @ lib-CvsIybF7.js:385
    2.9ms  reconcileChildFibersImpl @ react-dom_client.js:3776
    2.6ms  measure @ :-1
    2.5ms  ReactStrictModeWarnings.recordLegacyContextWarning @ react-dom_client.js:12834
    2.4ms  validatePropertiesInDevelopment @ react-dom_client.js:9493
    2.4ms  validateProperty @ react-dom_client.js:1874
    2.3ms  completeWork @ react-dom_client.js:6275
    1.8ms  jsxDEVImpl @ react_jsx-dev-runtime.js:147
    --- by script ---
    422.3ms  (program))
    422.3ms  (idle))
    157.5ms  conversation-message-overflow.tsx
    111.2ms  react-dom_client.js
    79.8ms  react_jsx-dev-runtime.js
    48.4ms  react_jsx-runtime.js
    39.8ms  run)
    19.8ms  (garbage collector))
    15.9ms  react.js
    11.7ms  react-markdown.js
    9.7ms  bottom-anchored-scroll-body.tsx
    7.6ms  now)
    6.4ms  createTask)
    5.9ms  setAttribute)
    4.4ms  blank-line-CbzQQYtI.js
    

    (program) is native work the JS profiler cannot attribute (style/layout/paint of the growing tree). readOverflowMeasurement is a scrollHeight/clientHeight read inside a useLayoutEffect, so its "self time" is a forced synchronous layout of the whole document. The rest is React mounting ~150 rows' worth of components (jsx runtime + react-dom) and markdown parsing.

  6. Unit-level repro that fails on main (ThreadTimelineRows.issue-1301.test.tsx, drop it into apps/app/src/components/thread/timeline/ and run pnpm exec vitest run src/components/thread/timeline/ThreadTimelineRows.issue-1301.test.tsx from apps/app). The failing assertion is expect(withContent.length).toBeLessThan(200): all 800 rows are mounted with real content.
    // @vitest-environment jsdom
    //
    // Repro for issue #1301: ThreadTimelineRows mounts every loaded row with its
    // full content. There is no windowing, so mounted DOM grows linearly with the
    // number of pages the user has scrolled through. This test FAILS on main
    // (16ceb3a54): all 800 rows are mounted with real content.
    
    import { cleanup, render } from &quot;@testing-library/react&quot;;
    import { afterEach, describe, expect, it } from &quot;vitest&quot;;
    import { MemoryRouter } from &quot;react-router-dom&quot;;
    import { CompactViewportOverrideProvider } from &quot;@bb/shared-ui/hooks/use-compact-viewport&quot;;
    import { conversationRow } from &quot;@/test/fixtures/thread-timeline-rows&quot;;
    import { ThreadTimelineRows } from &quot;./ThreadTimelineRows&quot;;
    
    afterEach(() =&gt; cleanup());
    
    describe(&quot;issue #1301: timeline history is never unmounted&quot;, () =&gt; {
      it(&quot;bounds the number of rows mounted with real content on a compact viewport&quot;, () =&gt; {
        const rows = Array.from({ length: 800 }, (_, index) =&gt;
          conversationRow({
            id: `message_${index}`,
            role: index % 2 === 0 ? &quot;user&quot; : &quot;assistant&quot;,
            text: `Timeline message ${index}`,
            sourceSeqStart: index + 1,
            sourceSeqEnd: index + 1,
            threadId: &quot;thr_1301&quot;,
          }),
        );
        const { container } = render(
          &lt;MemoryRouter&gt;
            &lt;CompactViewportOverrideProvider isCompactViewport&gt;
              &lt;ThreadTimelineRows
                threadId=&quot;thr_1301&quot;
                timelineRows={rows}
                threadRuntimeDisplayStatus=&quot;idle&quot;
                workspaceRootPath={undefined}
              /&gt;
            &lt;/CompactViewportOverrideProvider&gt;
          &lt;/MemoryRouter&gt;,
        );
        const wrappers = container.querySelectorAll(&quot;[data-timeline-row-id]&quot;);
        const withContent = Array.from(wrappers).filter(
          (wrapper) =&gt; wrapper.textContent !== null &amp;&amp; wrapper.textContent.length &gt; 0,
        );
        // Every row wrapper is present (that is fine) ...
        expect(wrappers.length).toBe(800);
        // ... but a windowed list would realize only rows near the viewport.
        // On main every one of the 800 rows carries its full rendered content.
        expect(withContent.length).toBeLessThan(200);
      });
    });
    
    Output on main:
     RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_debcf606-e4a-19/apps/app
     ❯ src/components/thread/timeline/ThreadTimelineRows.issue-1301.test.tsx (1 test | 1 failed) 1179ms
         × bounds the number of rows mounted with real content on a compact viewport 1178ms
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
     FAIL  src/components/thread/timeline/ThreadTimelineRows.issue-1301.test.tsx > issue #1301: timeline history is never unmounted > bounds the number of rows mounted with real content on a compact viewport
    AssertionError: expected 800 to be less than 200
     ❯ src/components/thread/timeline/ThreadTimelineRows.issue-1301.test.tsx:49:32
         47|     // ... but a windowed list would realize only rows near the viewpo…
         48|     // On main every one of the 800 rows carries its full rendered con…
         49|     expect(withContent.length).toBeLessThan(200);
           |                                ^
         50|   });
         51| });
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
     Test Files  1 failed (1)
          Tests  1 failed (1)
       Start at  05:18:29
       Duration  4.14s (transform 1.10s, setup 19ms, import 2.46s, tests 1.18s, environment 398ms)
    

Root cause

Two pieces of client code combine to produce the symptom; neither has a bug in the "wrong output" sense — the design simply has no upper bound.

1. Older pages are accumulated into one array

apps/app/src/components/thread/timeline/useThreadTimelineController.ts#L227-L236prependOlderTimelineRows concatenates every fetched page in front of what is already loaded, and apps/app/src/components/thread/timeline/useThreadTimelineController.ts#L496-L515 stores the result in loadedTimeline.rows. There is no eviction, cap, or "distance from viewport" notion anywhere in the controller. Rows are only dropped when the latest window becomes non-contiguous (isLatestTimelineWindowContiguous) or the surface key changes.

export function prependOlderTimelineRows({ loadedRows, olderRows }) {
  const rows: TimelineRow[] = [];
  appendTimelineRowsPreservingOrder(rows, olderRows);
  appendTimelineRowsPreservingOrder(rows, loadedRows);
  return rows;
}

Auto-loading makes this fast to trigger: apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts#L9-L9 prefetches the next page whenever the sentinel is within 600 px of the viewport, so a flick to the top fetches page after page.

2. Every loaded row is rendered as real DOM

apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx#L1906-L1928TimelineRowsList maps all items to <div data-timeline-row-id><MemoizedTimelineRowView …/></div>. Memoisation (and identity preservation in the controller) keeps already-mounted rows from re-rendering, so React work per page is roughly O(page), but the browser's style/layout cost is against the whole subtree, and DOM/heap memory is O(everything ever loaded). Each conversation row is a full markdown render (~25 nodes per row on this fixture) plus a message action bar, so 820 rows ≈ 21k nodes.

Why the per-page long task is 250–600 ms

Because layout cost scales with total mounted DOM, later pages are slower than earlier ones (296 ms for the first page vs. 418–472 ms for the last ones in the wheel run) even though pages have similar row counts. This is why the issue calls it "a series of visible freezes": each is a single sync task with no yielding.

Deeper / adjacent issues

Proposed fix (first principles)

Confidence is high on the mechanism, so a fix direction is reasonable to state. Two independent layers, both client-only (the paging protocol is fine):

  1. Bound what is rendered — window the top-level TimelineRowsList so only rows near the viewport (± ~1 viewport of margin) mount real content; rows outside keep a fixed-height in-flow placeholder sized from their last measured height (estimate before first measurement). Keying on the existing stable row ids means realised rows keep identity across streaming updates. This is exactly what PR #1384 does; the essential requirements it should meet are: no absolute positioning (so native scroll anchoring and existing bottom-anchoring keep working), height placeholders in normal flow, realisation on IntersectionObserver, and pinning rows the user has interacted with (expanded tool calls, selections). It should apply at all viewport widths, not only < 768px, and must not swap wrapper component types when it turns on/off (see PR review).
  2. Bound what is loaded (optional, memory) — cap loadedTimeline.rows at N pages and drop pages far from the viewport, keeping both cursors, so scrolling back down re-fetches. This is more invasive (the controller currently assumes the loaded rows are a contiguous prefix ending at the live window) and windowing alone removes the user-visible freezes, so it can be a follow-up.
  3. Reduce per-page commit cost regardless: batch the overflow measurement so a page of new rows does one layout read (e.g. via the shared ResizeObserver only, dropping the synchronous first read, or measuring in one requestAnimationFrame), and skip AutoHeightContainer's height animation for prepends. These shave the constant factor but do not fix the unbounded growth.

Risks: windowing breaks find-in-page and screen-reader linear reading for derealised rows, needs care with scroll restoration on prepend (placeholder estimate vs real height), and interacts with the streaming path (active turn rows must never derealise). All are addressed, with varying complexity, in #1384.

PR review — #1384 "Window large mobile timelines" (open, not linked to #1301)

PR #1384 by SawyerHood (agent-generated, GPT-5.6 + Claude), branch bb/mobile-timeline-virtualization, 8 commits, +1,887/−37 across ThreadTimelineRows.tsx, ThreadTimelineRows.actions.test.tsx, useScrollToSearchedMessage.ts. Full diff saved at pr1384.diff. It is not linked from the issue but is the only open work that addresses it, so it is reviewed here.

What it changes

Does it address the root cause?

Yes for compact viewports: it bounds mounted content (layer 1 above). It does not bound the loaded rows array (layer 2, acceptable) and does nothing for widths ≥768 px.

Tests I ran

PR 1384 mid scroll
PR #1384 (main merged), mid history: only ~30 rows realised, rest are placeholders; nothing blank in view.
PR 1384 top of history
PR #1384 at the top of history: 820 wrappers, 16 realised, 1,675 DOM nodes.

Findings

#WhereSeverityFinding
1ThreadTimelineRows.tsx shouldWindow (PR diff ~L2184-2190) and the two return branches of TimelineRowsListHighWindowing toggles by swapping the per-row wrapper element type (<div><TimelineWindowedListItem>) under the same key. When items.length crosses 40 (every mobile thread does this mid-conversation as messages stream in), or the viewport crosses 768 px (tablet rotate, desktop resize, split view), React unmounts and remounts every row: expanded tool-call/turn state, in-progress text selection, "show more" reveals and scroll geometry are lost, and the remount is itself a large sync task at the worst moment. Repro test above fails. Fix: always render the same wrapper component and make it a no-op passthrough (always realised) when windowing is off, or key the list on shouldWindow deliberately and document the reset.
2shouldWindow requires useIsCompactViewport()MediumOnly <768px is windowed. The unbounded growth (and #1304's composer cost) is identical at desktop widths — measured 21k nodes / 589 ms tasks on the PR branch at 1280 px. The PR title scopes to mobile, but the WebKit-momentum machinery is the hard part; enabling the window on desktop is mostly removing the guard, and shipping it mobile-only leaves the issue open.
3Base drift; AutoHeightContainerMedium (mergeability)Branch is 151 commits behind main and conflicts with #1568's snapRevision. The PR also removes AutoHeightContainer from the windowed path entirely, so the height-snap-on-turn-completion behaviour from #1568 does not apply on mobile once windowed. Whether that is acceptable needs a deliberate call; my merge only threads the prop through the non-windowed path.
4TimelineRowsList hooks: useIsCompactViewport, useBottomAnchoredScroll, useStore, useLocation, several useMemosLowTimelineRowsList is also rendered recursively for nested (turn/delegation) lists where shouldWindow is always false, so every nested list now subscribes to the media query and router and computes search/unread/anchor indices it never uses. Cheap individually, but the file is now ~3,150 lines with the windowing state machine inline; extracting useTimelineWindow to its own module would keep the nested path free of it.
5estimateTimelineListItemHeight (120 / 40 px), TIMELINE_WINDOW_REBUDGET_*LowStatic estimates seed placeholders; on this fixture assistant messages are 300–900 px, so scrollHeight swings by ~1.7 kpx during a scroll (69,374 → 68,302 → 70,073 in my runs) as estimates are replaced. In Chromium this was invisible; on WebKit the PR's donor scheme is designed for it. Worth keeping the iOS drift check from the PR description as an automated Playwright/WebKit test rather than a one-off measurement.
6Placeholders are aria-hidden with empty contentLow (inherent)Find-in-page and assistive tech cannot reach derealised rows; the in-app search deep link is handled. Acceptable trade-off for a virtualised list but should be stated in the PR.
7Type/contract hygieneLowNo as any/unknown smuggling found; only a { top, bottom } as DOMRect in tests. No server/daemon boundary or protocol changes (client only), so no HOST_DAEMON_PROTOCOL_VERSION concern.

Verdict

REQUEST CHANGES. The approach is right and it demonstrably fixes the measured problem on compact viewports (21k → 1.7k nodes, 280–470 ms → 60–170 ms tasks on the same repro). But it must be rebased onto main, finding 1 (full remount when windowing switches on at 40 items or on a viewport-width change) needs fixing with a test, and it should either enable the window at all widths or explicitly leave #1301 open for desktop.

Related issues

Appendix

Commands run (in order)
# worktree setup
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
# dev instance + fixture
scripts/bb-dev-app current ; pnpm dev:stop
pnpm seed:perf -- --projects 1 --threads 8 --events 60000 --seed 42
scripts/bb-dev-app current
sqlite3 <data>/bb.db "select thread_id, count(*) c from events group by thread_id order by c desc limit 8;"
curl -s -o /tmp/1301-latest.json -w 'status=%{http_code} bytes=%{size_download} time=%{time_total}\n' http://localhost:21028/api/v1/threads/thr_wfjb5qctw4/timeline
node /tmp/bb-reports/issues/1301-walk-pages.mjs
# browser (dev-browser CLI, Playwright Chromium headless)
dev-browser --browser bb1301 --headless --timeout 120 run browser-01-open.js
dev-browser --browser bb1301 --headless --timeout 300 run browser-02-scroll-history.js
dev-browser --browser bb1301 --headless --timeout 300 run browser-03-wheel-scroll.js
dev-browser --browser bb1301 --headless --timeout 300 run browser-04-profile.js
# unit repro on main
cd apps/app && pnpm exec vitest run src/components/thread/timeline/ThreadTimelineRows.issue-1301.test.tsx
# PR 1384
git fetch origin pull/1384/head:pr-1384 ; git checkout -b pr-1384-merge pr-1384 ; git merge 16ceb3a54   # 1 conflict, resolved
pnpm install --frozen-lockfile --prefer-offline ; pnpm exec turbo run typecheck --filter=@bb/app
cd apps/app && pnpm exec vitest run src/components/thread/timeline/ src/components/ui/bottom-anchored-scroll-body
scripts/bb-dev-app current
dev-browser ... run browser-05-pr1384-wheel-scroll.js ; browser-06-pr1384-scroll-down.js ; browser-07-pr1384-desktop.js
cd apps/app && pnpm exec vitest run src/components/thread/timeline/ThreadTimelineRows.pr1384-threshold.test.tsx   # fails (finding 1)
git checkout 16ceb3a54 ; pnpm dev:stop
Latest-page fetch (curl)
status=200 bytes=109302 time=0.058081
rows 92 olderCursor {"anchorSeq":8182,"anchorId":"thr_wfjb5qctw4:user-seed:8182"} kinds conversation 35, turn 17, work 40
Scroll-down check on PR branch (browser-06 output)
{"label":"up-2","nodes":1675,"rows":820,"realized":16,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":0,"scrollHeight":70073}
{"label":"down-1","nodes":1927,"rows":820,"realized":35,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":4200,"scrollHeight":69534}
{"label":"down-2","nodes":1835,"rows":820,"realized":32,"visible":10,"blankVisible":0,"blankIds":[],"scrollTop":8400,"scrollHeight":69534}
{"label":"down-3","nodes":2244,"rows":820,"realized":38,"visible":11,"blankVisible":0,"blankIds":[],"scrollTop":12600,"scrollHeight":69411}
{"label":"down-4","nodes":1993,"rows":820,"realized":30,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":16800,"scrollHeight":69167}
{"label":"down-5","nodes":1927,"rows":820,"realized":31,"visible":10,"blankVisible":0,"blankIds":[],"scrollTop":21000,"scrollHeight":69167}
{"label":"down-6","nodes":2169,"rows":820,"realized":39,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":25200,"scrollHeight":69163}
{"label":"down-7","nodes":1894,"rows":820,"realized":33,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":29400,"scrollHeight":69052}
{"label":"down-8","nodes":1978,"rows":820,"realized":34,"visible":11,"blankVisible":0,"blankIds":[],"scrollTop":33600,"scrollHeight":69052}
{"label":"down-9","nodes":2042,"rows":820,"realized":30,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":37800,"scrollHeight":69052}
{"label":"down-10","nodes":2066,"rows":820,"realized":39,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":42000,"scrollHeight":68445}
{"label":"down-11","nodes":1896,"rows":820,"realized":34,"visible":9,"blankVisible":0,"blankIds":[],"scrollTop":46200,"scrollHeight":68445}
{"label":"down-12","nodes":1991,"rows":820,"realized":35,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":50400,"scrollHeight":68707}
{"label":"down-13","nodes":1980,"rows":820,"realized":34,"visible":13,"blankVisible":0,"blankIds":[],"scrollTop":54600,"scrollHeight":68707}
{"label":"down-14","nodes":1972,"rows":820,"realized":37,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":58800,"scrollHeight":68707}
{"label":"down-15","nodes":2031,"rows":820,"realized":41,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":63000,"scrollHeight":68302}
{"label":"down-16","nodes":1804,"rows":820,"realized":24,"visible":12,"blankVisible":0,"blankIds":[],"scrollTop":67200,"scrollHeight":68302}
{"label":"down-17","nodes":1730,"rows":820,"realized":20,"visible":8,"blankVisible":0,"blankIds":[],"scrollTop":67507,"scrollHeight":68303}
/home/sawyer/.dev-browser/tmp/1301-pr1384-after-scroll-down.png
Hostile PR test: threshold remount (source)
// @vitest-environment jsdom
//
// Hostile test for PR #1384 (issue #1301 review): crossing the 40-item
// windowing threshold on a compact viewport swaps every row&#x27;s wrapper element
// type (plain &lt;div&gt; -&gt; TimelineWindowedListItem), which remounts the whole
// timeline subtree and drops row-local state.

import { act, cleanup, render } from &quot;@testing-library/react&quot;;
import { afterEach, describe, expect, it, vi } from &quot;vitest&quot;;
import { MemoryRouter } from &quot;react-router-dom&quot;;
import { CompactViewportOverrideProvider } from &quot;@bb/shared-ui/hooks/use-compact-viewport&quot;;
import {
  BottomAnchorContext,
  type BottomAnchorContextValue,
} from &quot;@/components/ui/bottom-anchored-scroll-body&quot;;
import { conversationRow } from &quot;@/test/fixtures/thread-timeline-rows&quot;;
import { ThreadTimelineRows } from &quot;./ThreadTimelineRows&quot;;

afterEach(() =&gt; {
  cleanup();
  vi.unstubAllGlobals();
});

function makeRows(count: number) {
  return Array.from({ length: count }, (_, index) =&gt;
    conversationRow({
      id: `message_${index}`,
      role: index % 2 === 0 ? &quot;user&quot; : &quot;assistant&quot;,
      text: `Timeline message ${index}`,
      sourceSeqStart: index + 1,
      sourceSeqEnd: index + 1,
      threadId: &quot;thr_threshold&quot;,
    }),
  );
}

describe(&quot;PR #1384 windowing threshold&quot;, () =&gt; {
  it(&quot;remounts every timeline row when the 40th item arrives on a compact viewport&quot;, () =&gt; {
    vi.stubGlobal(
      &quot;IntersectionObserver&quot;,
      class IntersectionObserverMock {
        observe() {}
        unobserve() {}
        disconnect() {}
      },
    );
    const scrollElement = document.createElement(&quot;div&quot;);
    scrollElement.getBoundingClientRect = () =&gt;
      ({ top: 0, bottom: 800 }) as DOMRect;
    const bottomAnchor: BottomAnchorContextValue = {
      captureScrollAnchor: vi.fn(),
      getScrollElement: () =&gt; scrollElement,
      isAtBottom: true,
      scrollElementIntoView: vi.fn(),
      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
      scrollToBottom: vi.fn(),
    };
    const renderTimeline = (rowCount: number) =&gt; (
      &lt;MemoryRouter&gt;
        &lt;BottomAnchorContext.Provider value={bottomAnchor}&gt;
          &lt;CompactViewportOverrideProvider isCompactViewport&gt;
            &lt;ThreadTimelineRows
              threadId=&quot;thr_threshold&quot;
              timelineRows={makeRows(rowCount)}
              threadRuntimeDisplayStatus=&quot;idle&quot;
              workspaceRootPath={undefined}
            /&gt;
          &lt;/CompactViewportOverrideProvider&gt;
        &lt;/BottomAnchorContext.Provider&gt;
      &lt;/MemoryRouter&gt;
    );

    const { container, rerender } = render(renderTimeline(39));
    expect(container.querySelector(&#x27;[data-timeline-windowed=&quot;true&quot;]&#x27;)).toBeNull();
    const before = container.querySelector(&#x27;[data-timeline-row-id=&quot;message_0&quot;]&#x27;);
    expect(before).not.toBeNull();
    const beforeInner = before?.firstElementChild ?? null;
    expect(beforeInner).not.toBeNull();

    // Same thread, one more row appended (a new streamed message).
    act(() =&gt; {
      rerender(renderTimeline(40));
    });
    expect(
      container.querySelector(&#x27;[data-timeline-windowed=&quot;true&quot;]&#x27;),
    ).not.toBeNull();
    const after = container.querySelector(&#x27;[data-timeline-row-id=&quot;message_0&quot;]&#x27;);
    const afterInner = after?.firstElementChild ?? null;

    // The row content for message_0 did not change, so a stable list keeps
    // the same DOM nodes. If these differ, React unmounted and remounted the
    // row (and every other row) purely because the wrapper type changed.
    expect(after).toBe(before);
    expect(afterInner).toBe(beforeInner);
  });
});
Page walker script (1301-walk-pages.mjs)
const base = &quot;http://localhost:21028&quot;;
const threadId = process.argv[2] ?? &quot;thr_wfjb5qctw4&quot;;
let url = `${base}/api/v1/threads/${threadId}/timeline`;
let page = 0, total = 0, bytes = 0;
for (;;) {
  const t0 = performance.now();
  const res = await fetch(url);
  const text = await res.text();
  const ms = (performance.now() - t0).toFixed(0);
  const body = JSON.parse(text); if (!body.rows) { console.log(&quot;ERR&quot;, res.status, url, text.slice(0,300)); break; }
  page += 1; total += body.rows.length; bytes += text.length;
  const c = body.timelinePage.olderCursor;
  console.log(`page ${page} rows=${body.rows.length} bytes=${text.length} ms=${ms} olderCursor=${JSON.stringify(c)}`);
  if (!c) break;
  url = `${base}/api/v1/threads/${threadId}/timeline?beforeAnchorSeq=${c.anchorSeq}&amp;beforeAnchorId=${encodeURIComponent(c.anchorId)}`;
}
console.log(`TOTAL pages=${page} rows=${total} bytes=${bytes}`);
Browser scripts

browser-01-open.js

// dev-browser script: open the seeded 9,001-event thread at a 390x844 viewport
// and record the initial DOM size.
const page = await browser.getPage(&quot;thread&quot;);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(&quot;http://localhost:13028/projects/proj_mgvp7iamvh/threads/thr_wfjb5qctw4&quot;, { waitUntil: &quot;networkidle&quot;, timeout: 60000 });
await page.waitForTimeout(3000);
const info = await page.evaluate(() =&gt; {
  const rows = document.querySelectorAll(&quot;[data-timeline-row-id]&quot;).length;
  const nodes = document.getElementsByTagName(&quot;*&quot;).length;
  const scrollers = Array.from(document.querySelectorAll(&quot;*&quot;)).filter(e =&gt; { const s = getComputedStyle(e); return (s.overflowY===&quot;auto&quot;||s.overflowY===&quot;scroll&quot;) &amp;&amp; e.scrollHeight &gt; e.clientHeight + 10; }).map(e =&gt; ({tag:e.tagName, cls:e.className.toString().slice(0,80), sh:e.scrollHeight, ch:e.clientHeight, st:e.scrollTop}));
  return { rows, nodes, scrollers, url: location.href };
});
console.log(JSON.stringify(info, null, 2));
const shot = await page.screenshot();
console.log(await saveScreenshot(shot, &quot;1301-initial.png&quot;));

browser-03-wheel-scroll.js

// dev-browser script: reload the thread, then scroll up with real wheel events
// (like a user flicking upward) until history is exhausted. Records DOM/rows
// after each burst and lists long tasks.
const page = await browser.getPage(&quot;thread&quot;);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(&quot;http://localhost:13028/projects/proj_mgvp7iamvh/threads/thr_wfjb5qctw4&quot;, { waitUntil: &quot;networkidle&quot;, timeout: 60000 });
await page.waitForTimeout(2500);
await page.evaluate(() =&gt; {
  window.__bb1301 = { longTasks: [], samples: [] };
  const po = new PerformanceObserver((list) =&gt; {
    for (const e of list.getEntries()) window.__bb1301.longTasks.push({ start: Math.round(e.startTime), dur: Math.round(e.duration) });
  });
  po.observe({ type: &quot;longtask&quot;, buffered: false });
});
const sample = (label) =&gt; page.evaluate((label) =&gt; {
  const el = document.querySelector(&quot;.thread-scrollbar&quot;);
  return { label, nodes: document.getElementsByTagName(&quot;*&quot;).length, rows: document.querySelectorAll(&quot;[data-timeline-row-id]&quot;).length, scrollHeight: el.scrollHeight, scrollTop: el.scrollTop, longTasks: window.__bb1301.longTasks.length };
}, label);
console.log(JSON.stringify(await sample(&quot;before&quot;)));
await page.mouse.move(195, 400);
let lastRows = -1, stable = 0, tookMid = false;
for (let i = 1; i &lt;= 60; i++) {
  for (let k = 0; k &lt; 40; k++) { await page.mouse.wheel(0, -1500); await page.waitForTimeout(20); }
  await page.waitForTimeout(800);
  const s = await sample(&quot;burst-&quot; + i);
  console.log(JSON.stringify(s));
  if (!tookMid &amp;&amp; s.rows &gt; 300) { tookMid = true; console.log(await saveScreenshot(await page.screenshot(), &quot;1301-mid-scroll.png&quot;)); }
  if (s.rows === lastRows &amp;&amp; s.scrollTop === 0) { stable += 1; if (stable &gt;= 2) break; } else stable = 0;
  lastRows = s.rows;
}
const lt = await page.evaluate(() =&gt; window.__bb1301.longTasks);
console.log(&quot;LONGTASKS &quot; + JSON.stringify(lt));
console.log(await saveScreenshot(await page.screenshot(), &quot;1301-top-of-history.png&quot;));

browser-04-profile.js

// dev-browser script: CPU-profile a single older-page prepend via CDP and print
// the top self-time frames plus how much of the wall time was React render/commit
// vs. browser layout. Also measures the &quot;empty&quot; cost of scrolling with no load.
const page = await browser.getPage(&quot;thread&quot;);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(&quot;http://localhost:13028/projects/proj_mgvp7iamvh/threads/thr_wfjb5qctw4&quot;, { waitUntil: &quot;networkidle&quot;, timeout: 60000 });
await page.waitForTimeout(2500);
const cdp = await page.context().newCDPSession(page);
await cdp.send(&quot;Profiler.enable&quot;);
await cdp.send(&quot;Profiler.setSamplingInterval&quot;, { interval: 200 });

async function profileOnePage(label) {
  const before = await page.evaluate(() =&gt; document.querySelectorAll(&quot;[data-timeline-row-id]&quot;).length);
  await cdp.send(&quot;Profiler.start&quot;);
  const t0 = Date.now();
  await page.evaluate(() =&gt; { const el = document.querySelector(&quot;.thread-scrollbar&quot;); el.scrollTop = 0; });
  // wait for one page to land
  await page.waitForFunction((n) =&gt; document.querySelectorAll(&quot;[data-timeline-row-id]&quot;).length &gt; n, before, { timeout: 15000 }).catch(() =&gt; {});
  await page.waitForTimeout(700);
  const { profile } = await cdp.send(&quot;Profiler.stop&quot;);
  const after = await page.evaluate(() =&gt; document.querySelectorAll(&quot;[data-timeline-row-id]&quot;).length);
  // aggregate self time by function
  const byId = new Map(profile.nodes.map((n) =&gt; [n.id, n]));
  const self = new Map();
  const dt = profile.timeDeltas; const samples = profile.samples;
  let total = 0;
  for (let i = 0; i &lt; samples.length; i++) {
    const n = byId.get(samples[i]); const d = (dt[i] || 0) / 1000; total += d;
    const cf = n.callFrame; const url = (cf.url || &quot;&quot;).split(&quot;/&quot;).slice(-1)[0].split(&quot;?&quot;)[0];
    const key = (cf.functionName || &quot;(anon)&quot;) + &quot; @ &quot; + url + &quot;:&quot; + cf.lineNumber;
    self.set(key, (self.get(key) || 0) + d);
  }
  const top = [...self.entries()].sort((a, b) =&gt; b[1] - a[1]).slice(0, 30).map(([k, v]) =&gt; v.toFixed(1) + &quot;ms  &quot; + k);
  // bucket by url
  const byUrl = new Map();
  for (let i = 0; i &lt; samples.length; i++) { const n = byId.get(samples[i]); const d = (dt[i] || 0) / 1000; const url = (n.callFrame.url || &quot;(native/&quot; + n.callFrame.functionName + &quot;)&quot;).split(&quot;/&quot;).slice(-1)[0].split(&quot;?&quot;)[0]; byUrl.set(url, (byUrl.get(url) || 0) + d); }
  const urls = [...byUrl.entries()].sort((a, b) =&gt; b[1] - a[1]).slice(0, 15).map(([k, v]) =&gt; v.toFixed(1) + &quot;ms  &quot; + k);
  console.log(`=== ${label}: rows ${before} -&gt; ${after}, sampled ${total.toFixed(0)}ms ===`);
  console.log(top.join(&quot;\n&quot;));
  console.log(&quot;--- by script ---\n&quot; + urls.join(&quot;\n&quot;));
  await writeFile(`1301-profile-${label}.cpuprofile`, JSON.stringify(profile));
}
await profileOnePage(&quot;page1&quot;);
await profileOnePage(&quot;page2&quot;);
// load the rest to make the loaded window large, then profile again
for (let i = 0; i &lt; 6; i++) { await page.evaluate(() =&gt; { document.querySelector(&quot;.thread-scrollbar&quot;).scrollTop = 0; }); await page.waitForTimeout(1500); }
await profileOnePage(&quot;pageLate&quot;);

Verification

A second pass re-ran the report in a fresh checkout of the same worktree path (HEAD c25298f69, two commits past 16ceb3a54; git diff --stat 16ceb3a54 HEAD -- apps/app/src/components/thread/timeline touches only one test file, so the code under investigation is identical to the base commit). Findings:

Artifacts: 1301/repro/ (scripts, logs, cpuprofiles, diff, tests) · screenshots in assets/ (1301-*.png). Report written by an agent (Claude); numbers are from a dev build on desktop hardware.