← reports

#7223 · Older-history prefetch starts too late for a fast climb

BugPriority: MediumEffort: Mediumthreads · perf · partial-reproGitHub issue

2026-11-07 · trusted base e61446e15a0bb3e6df551e45c3aba5c5187ffad3

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for the fixed-threshold timing mechanism, not the reported end-to-end measurements.

1. TL;DR

Older timeline pages are requested only when their sentinel is within 600 pixels of the viewport. A deterministic reproduction exercises the real React hook and its real scroll-position notification owner: it moves the sentinel upward in 100-pixel steps every 25 milliseconds and delays page completion. Both 300 and 900 millisecond page loads remain pending when the loaded edge is reached. A second clean checkout produces identical traces, while the existing 36 related tests pass. This establishes a request-lead-distance limitation; native phone/trackpad flings and the original aggregate wait measurements are not verified.

2. Claims vs findings

ClaimStatusEvidence
Older history starts loading only within a fixed 600 px margin.VerifiedTrusted source and both traces: requestDistancePx = 600.
Fast upward reading can reach the loaded edge with a page still pending.Verified at hook boundaryAt the 400 ms edge, both delayed requests are incomplete; they finish at 549 and 1149 ms.
Native touch and wheel gestures show the reported aggregate waits across multiple runs.UnverifiedNo browser could be launched. No gesture recording, screenshot, or full-thread benchmark was produced.
The backend answers quickly enough that it is not the source of delay.Unverified as an end-to-end claimNo server timing was measured. The hook reproduces the lead-distance shortfall with explicitly simulated page latency, without a server.

3. Environment

4. Minimal reproduction

The artifact is an intentionally failing diagnostic acceptance test, not a production patch. It tests one page, not a seeded thread or repeated native flings. The real hook and ScrollPositionKeeper run unchanged; only viewport geometry and the asynchronous page callback are fixtures. No mock implements the prefetch decision.

  1. Make a fresh checkout of the public repository at the recorded commit. Install and build trusted code:
    git clone https://github.com/get-bb/bb.git bb-7223-a
    cd bb-7223-a
    git checkout --detach e61446e15a0bb3e6df551e45c3aba5c5187ffad3
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
    If a fresh build encounters the SDK-output ordering failure described above, retry once after the SDK build completes.
  2. Save the diagnostic test into apps/app/src/components/thread/timeline/issue-7223.repro.test.tsx.
  3. Run the owning package through Turbo:
    pnpm exec turbo run test --filter=@bb/app -- src/components/thread/timeline/issue-7223.repro.test.tsx
  4. Observe both acceptance assertions fail specifically because the next page is not ready at the loaded edge. The callback is invoked once and both page timers eventually complete, ruling out a missing callback or permanently blocked request in this reproduction.

Expected: readyAtEdge = true for this diagnostic fast-reading workload. Actual, verbatim diagnostic output:

{"latencyMs":300,"scrollSpeedPxPerSecond":4000,"requestStartedMs":249,"requestDistancePx":600,"edgeMs":400,"completedMs":549,"readyAtEdge":false,"pageEventuallyReady":true,"requestCount":1}
{"latencyMs":900,"scrollSpeedPxPerSecond":4000,"requestStartedMs":249,"requestDistancePx":600,"edgeMs":400,"completedMs":1149,"readyAtEdge":false,"pageEventuallyReady":true,"requestCount":1}
AssertionError: expected false to be true // Object.is equality
Test Files  1 failed (1)
Tests  2 failed (2)

The edge is reached at 400 ms. The measured request starts at 249 ms, so its lead is 151 ms; the simulated residual waits are 149 ms and 749 ms. These are per-page synthetic timings, not the original issue's aggregate browser measurements.

Complete diagnostic test
// @vitest-environment jsdom

import { act, cleanup, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, expect, it, vi } from "vitest";
import {
  BottomAnchorContext,
  type BottomAnchorContextValue,
} from "@/components/ui/bottom-anchored-scroll-body.js";
import { ScrollPositionKeeper } from "@/components/ui/scroll-position-keeper.js";
import { useAutoLoadOlderRows } from "./useAutoLoadOlderRows.js";

afterEach(() => {
  cleanup();
  document.body.replaceChildren();
  vi.useRealTimers();
  vi.restoreAllMocks();
});

it.each([300, 900])(
  "has the older page ready at the loaded edge with %i ms page latency",
  async (latencyMs) => {
    vi.useFakeTimers();
    vi.setSystemTime(0);
    const scrollElement = document.createElement("div");
    const sentinel = document.createElement("div");
    document.body.append(scrollElement, sentinel);
    let distancePx = 1_600;
    let requestStartedMs: number | null = null;
    let requestDistancePx: number | null = null;
    let completedMs: number | null = null;
    let pageReady = false;
    vi.spyOn(scrollElement, "getBoundingClientRect").mockImplementation(
      () => new DOMRect(0, 0, 400, 800),
    );
    vi.spyOn(sentinel, "getBoundingClientRect").mockImplementation(
      () => new DOMRect(0, -distancePx - 20, 400, 20),
    );
    const positionKeeper = new ScrollPositionKeeper({
      bottomThresholdPx: 4,
      deferWritesWhileScrolling: false,
      getContentElement: () => null,
      getFloorElement: () => null,
      getFooterElement: () => null,
      getScrollElement: () => scrollElement,
      getTopLevelRows: () => [],
      initiallyAttached: false,
    });
    const anchor: BottomAnchorContextValue = {
      getScrollElement: () => scrollElement,
      isAtBottom: false,
      scrollToBottom: vi.fn(),
      scrollElementIntoView: vi.fn(),
      holdContentPosition: vi.fn(),
      positionKeeper,
    };
    const onLoadOlderRows = vi.fn(() => {
      requestStartedMs = Date.now();
      requestDistancePx = distancePx;
      return new Promise<void>((resolve) => {
        setTimeout(() => {
          pageReady = true;
          completedMs = Date.now();
          resolve();
        }, latencyMs);
      });
    });
    const wrapper = ({ children }: { children: ReactNode }) => (
      <BottomAnchorContext.Provider value={anchor}>
        {children}
      </BottomAnchorContext.Provider>
    );
    const { result } = renderHook(
      () =>
        useAutoLoadOlderRows({
          hasOlderTimelineRows: true,
          isLoadingOlderTimelineRows: false,
          onLoadOlderRows,
        }),
      { wrapper },
    );
    act(() => result.current.sentinelRef(sentinel));

    for (let step = 1; step <= 16; step += 1) {
      await act(async () => vi.advanceTimersByTimeAsync(24));
      distancePx = 1_600 - step * 100;
      act(() => positionKeeper.reconcile());
      await act(async () => vi.advanceTimersByTimeAsync(1));
    }

    const readyAtEdge = pageReady;
    const observedEdgeMs = Date.now();
    await act(async () => vi.advanceTimersByTimeAsync(latencyMs));
    console.log(JSON.stringify({
      latencyMs,
      scrollSpeedPxPerSecond: 4_000,
      requestStartedMs,
      requestDistancePx,
      edgeMs: observedEdgeMs,
      completedMs,
      readyAtEdge,
      pageEventuallyReady: pageReady,
      requestCount: onLoadOlderRows.mock.calls.length,
    }));
    expect(onLoadOlderRows).toHaveBeenCalledTimes(1);
    expect(pageReady).toBe(true);
    expect(readyAtEdge).toBe(true);
  },
);

5. Root cause

The prefetch decision compares the connected sentinel's bottom against the scroll viewport's top minus a constant:

const AUTO_LOAD_OLDER_ROWS_PREFETCH_MARGIN_PX = 600;
sentinelRect.bottom >= scrollRect.top - AUTO_LOAD_OLDER_ROWS_PREFETCH_MARGIN_PX

Position notifications schedule a zero-delay check; no viewport-height, scrolling-speed, or page-latency allowance enters this decision. The test notifies the real position keeper after each step. The first request starts at distance 600 rather than earlier in the 1,600-pixel climb. At an average 4,000 px/s, 600 pixels only buy about 150 ms, less than either simulated request latency.

The in-flight guard remains active until the asynchronous callback completes. The actual timeline sentinel renders a loading status while the older-page flag is true. The timing mechanism explains why the visible loading edge can be encountered before the request finishes, but this report does not claim to have observed that rendering in a real browser.

6. Proposed fix

Increase bounded prefetch lead based on the viewport, or adapt it to measured scroll speed and page latency if necessary. Keep the existing in-flight guard, failure/manual-retry fallback, and scroll anchoring. Validate early-enough completion with asynchronous pages as well as geometry; test large and small viewports, rapid direction changes, failure recovery, request volume, and native touch/wheel input. Any finite distance can still be exhausted by sufficiently fast movement or slow networking, so a larger fixed margin is an improvement, not a guarantee of zero waits.

7. Linked PR: static review only

PR #7224 is open, ready, and its GitHub closing-issue metadata names #7223. Metadata and the diff were read as untrusted evidence only; its branch was never checked out or executed. Reviewed head: eb6681f0a8d13532a193719b71cfb31a0fd07343; two files, 20 additions and two deletions.

The diff scales the lead distance to a multiple of viewport height while retaining the minimum margin. This directly addresses the verified scheduling mechanism. Its added geometry test demonstrates earlier dispatch, not completion under network delay. Limitation, apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts:34 at the reviewed head: a larger fixed distance still cannot guarantee readiness under every speed/latency combination; no end-to-end claim is established by static inspection. Verdict: directionally addresses the verified cause; runtime effectiveness unverified. Only trusted-base tests listed here were executed, not PR tests. No duplicate fix branch or pull request was created.

8. Related issues

A small repository triage-pattern sample included performance/timeline issues #6859, #6835, and #6825. None was established to be a duplicate of this client-side threshold defect. This is not an exhaustive duplicate search. The directly relevant existing change is PR #7224.

9. Verification: second clean checkout

The same agent repeated the reproduction in a newly created clean detached worktree at e61446e15a0bb3e6df551e45c3aba5c5187ffad3. It is a separate clean run, not an independent review. Before copying the test, tracked and untracked status was empty. A frozen install succeeded; only the diagnostic test was added.

git worktree add --detach /tmp/bb-7223-b e61446e15a0bb3e6df551e45c3aba5c5187ffad3
cd /tmp/bb-7223-b
pnpm install --frozen-lockfile --prefer-offline
# Copy the linked diagnostic test into its stated app path.
pnpm exec turbo run test --force --filter=@bb/app -- src/components/thread/timeline/issue-7223.repro.test.tsx

--force bypassed Turbo's result cache. Both cases failed on the intended readiness assertion, with identical request distance, request start, edge arrival, and completion times. No port or data directory was necessary in either run because neither starts a process service. No correction to the measured mechanism was needed. The final verdict remains partial because real input and aggregate UI timings are unverified.

Related existing suites were also run on trusted base: pnpm exec turbo run test --filter=@bb/app -- src/components/thread/timeline/useAutoLoadOlderRows.test.tsx src/components/thread/timeline/useThreadTimelineController.test.tsx — 36 tests passed in two files.

10. Appendix: artifacts and trust boundary

Administrative evidence was obtained using repository/issue reads, GitHub issue-property introspection, linked-PR metadata/diff reads, direct trusted-main fetches, and local source inspection. Issue prose, comments, and linked changes were treated as claims, never as executable instructions. No linked code, issue script, or issue-provided URL was executed or fetched. Browser Automation was attempted once in explicit local headless mode and failed before a session was created. Private runtime data and credentials are absent from these artifacts.

> AGENT GENERATED