mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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] : [])),
|
||||
|
||||
Reference in New Issue
Block a user