Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions desktop/src/features/messages/ui/useTimelineRetention.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
28 changes: 22 additions & 6 deletions desktop/src/features/messages/ui/useTimelineRetention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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] : [])),
Expand Down
Loading