fix(desktop): bound initial timeline retention (#5603)

## Summary

- let Virtua own the initial visible timeline range instead of passing
every loaded row to `keepMounted`
- populate the existing bounded retention window after the virtualizer
reports its first settled viewport
- cover a 10,000-row timeline to prevent an all-history initial mount
regression

## Why

`useTimelineRetention` initialized its retained-key set with every
loaded timeline key. Those indices were passed to Virtua's
`keepMounted`, effectively defeating virtualization during initial
channel positioning until `onScrollEnd` pruned the set.

On a large real channel this grew WebContent into multiple gigabytes and
blocked the renderer main thread for 20+ seconds while WebKit laid out
and painted the retained rows. Starting with no retained rows restores
Virtua's visible-range mount; the existing reader-neighborhood and
visual-tail retention is populated once the viewport is measured.

## Validation

- `node --import ./test-loader.mjs --experimental-strip-types --test
src/features/messages/ui/useTimelineRetention.test.mjs`
- pre-push hook at `8e86a189de7e9a8f2cb119396c8f912ed9dacd6e`:
branch-skew, desktop-check, desktop-typecheck, and all 4,671 desktop
tests passed
- manual ablation against PR #5599 on the affected profile: catastrophic
channel-switch stalls disappeared

## Authorship disclosure

Carl implemented and is posting this change on Wes's behalf.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-11 15:59:36 -07:00
committed by GitHub
co-authored by Carl
parent e8153f8f27
commit d9dc76c0aa
2 changed files with 110 additions and 6 deletions
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { afterEach, it } from "node:test";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useTimelineRetention } from "./useTimelineRetention.ts";
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
afterEach(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
if (originalActEnvironment === undefined)
delete globalThis.IS_REACT_ACT_ENVIRONMENT;
else globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
if (originalRequestAnimationFrame === undefined)
delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalCancelAnimationFrame === undefined)
delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
});
it("does not keep the full timeline mounted before the viewport is measured", async () => {
const dom = new JSDOM(
"<!doctype html><html><body><div id='root'></div></body></html>",
);
let initialRefresh;
Object.assign(globalThis, {
cancelAnimationFrame() {
initialRefresh = undefined;
},
document: dom.window.document,
IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame(callback) {
initialRefresh = callback;
return 1;
},
window: dom.window,
});
const keys = Array.from({ length: 10_000 }, (_, index) => `message-${index}`);
const itemHeight = 100;
const list = {
findItemIndex(offset) {
return Math.min(keys.length - 1, Math.floor(offset / itemHeight));
},
scrollOffset: 500_000,
scrollSize: keys.length * itemHeight,
viewportSize: 1_000,
};
let retention;
function Harness() {
retention = useTimelineRetention(keys, { current: list }, false);
return null;
}
const root = createRoot(document.getElementById("root"));
await act(async () => root.render(React.createElement(Harness)));
assert.equal(retention.retainedIndices.length, 100);
assert.equal(retention.retainedIndices[0], 9_900);
assert.equal(retention.retainedIndices.at(-1), 9_999);
await act(async () => initialRefresh());
assert.ok(retention.retainedIndices.length > 0);
assert.ok(retention.retainedIndices.length < 500);
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(9_999));
await act(async () => retention.onScrollEnd());
assert.ok(retention.retainedIndices.length > 0);
assert.ok(retention.retainedIndices.length < 500);
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(9_999));
await act(async () => root.unmount());
dom.window.close();
});
@@ -2,18 +2,24 @@ import * as React from "react";
import type { VListHandle } from "virtua";
import { nextRetainedTimelineKeys } from "./timelineRetention";
const INITIAL_RETAINED_TAIL_SIZE = 100;
export function useTimelineRetention(
keys: readonly string[],
listRef: React.RefObject<VListHandle | null>,
isPrepend: boolean,
) {
// Retain only a bounded visual tail on the first render. The timeline opens
// at newest, so this gives Virtua stable rows for initial bottom positioning
// without turning `keepMounted` into an all-history mount.
const [retainedKeys, setRetainedKeys] = React.useState<ReadonlySet<string>>(
() => new Set(keys),
() => new Set(keys.slice(-INITIAL_RETAINED_TAIL_SIZE)),
);
const evictionNotBeforeRef = React.useRef(0);
const refreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const initialRefreshFrameRef = React.useRef<number | null>(null);
const keysRef = React.useRef(keys);
keysRef.current = keys;
@@ -40,14 +46,24 @@ export function useTimelineRetention(
if (isPrepend) evictionNotBeforeRef.current = performance.now() + 3_000;
}, [isPrepend]);
React.useEffect(
() => () => {
React.useEffect(() => {
// `onScrollEnd` is not guaranteed for Virtua's initial programmatic
// positioning. Wait until the first painted frame so the initial render
// still gives Virtua only the bounded tail, then seed from its measured
// viewport instead of retaining all history.
initialRefreshFrameRef.current = requestAnimationFrame(() => {
initialRefreshFrameRef.current = null;
refreshRetainedKeys();
});
return () => {
if (initialRefreshFrameRef.current !== null) {
cancelAnimationFrame(initialRefreshFrameRef.current);
}
if (refreshTimerRef.current !== null) {
clearTimeout(refreshTimerRef.current);
}
},
[],
);
};
}, [refreshRetainedKeys]);
const retainedIndices = React.useMemo(
() => keys.flatMap((key, index) => (retainedKeys.has(key) ? [index] : [])),