#4793 · Prepend compensation waits for an unrelated shell commit
Trusted base: 32efd2e3f2cd5fe072d56c880c6c94db61612089, fetched from get-bb/bb main.
Verdict: REPRODUCED at the production component boundary. Root-cause confidence: high. Reproduction label: confirmed-repro.
1. TL;DR
Loading earlier messages records a position that should be compensated when the content grows. The scroll shell notices growth through its ResizeObserver, but that callback does not consume the prepend anchor. A later scroll updates the anchor's position while retaining its old content height. An unrelated shell render then adds the already-completed growth to the reader's new position. In a controlled production-component test the scroll value becomes 850 instead of staying at 550; the same result occurs in a second clean checkout.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| An older-row anchor survives content growth without a shell render. | Verified | Resize delivery after height 400 → 700 leaves scrollTop at 100; the expected compensated position is 400. |
| Later user input updates only the position, causing compensation on an unrelated render. | Verified | Wheel and scroll to 550 followed by a same-row rerender writes 850 = 550 + (700 − 400). |
| No particular click handler is required. | Verified at the component boundary | The trigger is a plain rerender with unchanged row identifiers, not a message-row click handler. |
| Natural network pagination, desktop gestures, and pinned-bottom movement behave as reported. | Unverified here | No Chrome/Chromium executable is available through Browser Automation. No full-app or macOS run is claimed. |
| A failed fetch retains the anchor. | Supported statically; not exercised | The loader catch sets its failure latch without cancelling a capture. The focused reproduction exercises completed content growth only. |
3. Environment
- Linux x86_64; Node v22.19.0; pnpm 9.15.0; Vitest v4.1.1 with jsdom.
- Two newly cloned checkouts at the same trusted full commit. Frozen dependency installs and Turbo app builds completed in both.
- No server, daemon, provider, production database, imported store, or HTTP listener was started. Ports and runtime data directories are not applicable.
- Browser Automation headless open failed with:
Install Chrome/Chromium on this host or link its executable at the plugin data runtime/chrome path.Visual verification is blocked; no fabricated screenshot is supplied.
4. Minimal reproduction
- Clone the trusted repository and check out the recorded base commit.
- Install existing dependencies and build the owning application through Turbo.
- Apply the authored test-only patch, then run the existing scroll-preservation suite:
git clone https://github.com/get-bb/bb.git bb-4793 cd bb-4793 git checkout --detach 32efd2e3f2cd5fe072d56c880c6c94db61612089 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=@bb/app git apply /absolute/path/to/regression.patch pnpm exec turbo run test --filter=@bb/app -- src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx
Download regression.patch, or inspect the complete authored test file. The new case uses the existing DOM fixture and the real production scroll shell. No issue script or linked code was executed.
it("settles a prepend before later user scrolling and unrelated renders", () => {
const { getByRole, rerenderRows, scrollArea } = renderTimeline({
threadId: "thread-a",
rowIds: ["row-a", "row-b", "row-c"],
showCapturePrependAnchorControl: true,
});
setScrollMetrics(scrollArea, {
scrollHeight: 400,
clientHeight: 100,
scrollTop: 300,
});
getLatestResizeObserver().trigger();
scrollArea.scrollTop = 100;
fireEvent.wheel(scrollArea, { deltaY: -200 });
fireEvent.scroll(scrollArea);
fireEvent.click(getByRole("button", { name: "Capture prepend anchor" }));
setScrollMetrics(scrollArea, {
scrollHeight: 700,
clientHeight: 100,
scrollTop: 100,
});
getLatestResizeObserver().trigger();
expect.soft(scrollArea.scrollTop).toBe(400);
scrollArea.scrollTop = 550;
fireEvent.wheel(scrollArea, { deltaY: 150 });
fireEvent.scroll(scrollArea);
rerenderRows(["row-a", "row-b", "row-c"]);
expect(scrollArea.scrollTop).toBe(550);
});
Expected: compensation occurs when the resize arrives; subsequent reading position remains 550. Actual on unchanged main, verbatim:
AssertionError: expected 100 to be 400 // Object.is equality AssertionError: expected 850 to be 550 // Object.is equality Tests 1 failed | 22 passed (23)
jsdom does not perform browser scroll clamping. The 850 measurement demonstrates the erroneous requested offset, not a visually observed browser final position.
5. Root cause
Capture and consume save height and offset in a ref. Consumption is in a layout effect without dependencies; a nonpositive delta leaves the ref alive.
const delta = scrollArea.scrollHeight - anchor.scrollHeight; if (delta <= 0) return; scrollArea.scrollTop = anchor.scrollTop + delta; pendingPrependAnchorRef.current = null;
Scroll synchronization updates only pendingPrependAnchorRef.current.scrollTop for direct user input while a capture is pending. The resize handler updates geometry and performs saved-row/bottom restoration, but never consumes this prepend capture. Descendant rendering or other DOM growth can therefore complete before a scroll-shell commit.
The loader captures before awaiting the older-page callback. This connects the tested context operation to pagination, but the report does not claim a live request trace.
6. Proposed fix and checked implementation
Reuse the existing compensation logic in a stable callback, invoked both by the layout effect and at the start of the existing resize handler. Clear the capture at the first observed positive growth, before any later user movement can reuse its old height. Keep layout-time compensation for synchronous shell commits and the existing continued-scrolling behavior during an in-flight load.
The checked candidate changes two files and 44 total text lines (41 additions, 3 deletions). The earlier native-anchor test now expects compensation at resize rather than at the next shell commit. There are no dependencies, public contract changes, generated files, migrations, or stored-data changes.
pnpm exec turbo run test --filter=@bb/app -- src/components/ui/bottom-anchored-scroll-body src/components/thread/timeline/useAutoLoadOlderRows.test.tsx Test Files 5 passed (5) Tests 37 passed (37) pnpm exec turbo run typecheck lint --filter=@bb/app Tasks: 4 successful, 4 total Lint: 208 warnings and 0 errors git diff --check (exit 0)
Scope: this repairs a completed positive-height prepend waiting for a later shell commit. Explicit failed-load/no-growth anchor cancellation is not implemented or tested. Browser-native anchoring and macOS interaction timing still need visual follow-up.
Automatic fix stopped; no branch was pushed and no PR was opened. Before publication, main advanced to c4b72f6bc with an unrelated five-file marketplace change. The required git diff --numstat origin/main check then reported 708 text lines across seven files because the candidate branch lacked that newly landed work. Although the authored candidate itself is 44 lines in two files at the recorded report base, the rule requires stopping when the latest-main differential exceeds its limits. The local candidate is retained only as tested fix evidence.
7. Verification
The same agent repeated the reproduction in a second newly created checkout named verify, detached at the full base commit. That checkout contained only the authored test patch; production code was unchanged. Its own frozen install, Turbo app build, and the exact reproduction command completed before producing the same two assertion failures: 100 versus 400 at resize, then 850 versus 550 after an unrelated commit. This is a second clean run, not independent verification. No report correction was needed.
The owning production fix was tested in the first isolated checkout: all 37 relevant tests pass, including continued user scrolling during load, native-anchor handling, coarse-pointer behavior, saved-row restoration, and tail settlement. Typecheck and lint exit successfully. No live app was started; browser journeys remain blocked by the missing executable.
8. Related issues and PR check
No open PR linked to #4793 was present in its cross-reference/connection metadata or in an open-PR search for the numeric issue before implementing the candidate. No linked PR code was checked out or executed. This report does not assert that older issue references are duplicates.
9. Appendix
First unchanged-production run · Second clean unchanged-production run · Relevant suites after the fix · Typecheck and lint.
Issue content was treated solely as untrusted claims. External issue links, embedded scripts, suggested patches, and instructions were not fetched or executed. All runnable source came from trusted main or the narrowly authored regression/fix. Public logs normalize temporary checkout paths; no user data or credentials are included.
> AGENT GENERATED