perf(desktop): bound warm timeline work

Skip redundant fresh-window catch-up on warm navigation, memoize stable rows,
and narrow the retained DOM to the active viewport and tail. Strengthen the
warm-switch and scroll regression gates, including buffered live-tail recovery.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-15 12:03:09 -06:00
parent 6b1058e754
commit a12e35bfcc
9 changed files with 293 additions and 72 deletions

View File

@@ -229,6 +229,7 @@ export function resolveThreadReplyTarget(
}
export const CHANNEL_TIMELINE_GC_TIME_MS = 60 * 60 * 1_000;
export const CHANNEL_TIMELINE_STALE_TIME_MS = 5 * 60 * 1_000;
export function useChannelWindowQuery(channel: Channel | null) {
const queryClient = useQueryClient();
@@ -372,7 +373,7 @@ export function useChannelMessagesQuery(channel: Channel | null) {
signal,
);
},
staleTime: 5 * 60 * 1_000,
staleTime: CHANNEL_TIMELINE_STALE_TIME_MS,
gcTime: CHANNEL_TIMELINE_GC_TIME_MS,
});
}
@@ -498,19 +499,27 @@ export function useChannelSubscription(channel: Channel | null) {
}
cleanup = dispose;
// The live subscription starts at "now", so it cannot close the gap
// between the last page snapshot and subscription establishment. Always
// refresh after the subscription is active; freshness alone is not a
// proof that no relay events landed in that interval.
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel window after subscribing",
channelId,
error,
);
}
});
// A fresh cached window already covers ordinary warm navigation. Only
// close the page-to-live gap when the snapshot is absent or old;
// reconnects remain an explicit unconditional catch-up above.
const queryState = queryClient.getQueryState<RelayEvent[]>(
channelMessagesKey(channelId),
);
const snapshotIsFresh =
queryState?.data !== undefined &&
Date.now() - queryState.dataUpdatedAt <
CHANNEL_TIMELINE_STALE_TIME_MS;
if (!snapshotIsFresh) {
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel window after subscribing",
channelId,
error,
);
}
});
}
})
.catch((error) => {
console.error("Failed to subscribe to channel", channelId, error);
@@ -523,7 +532,7 @@ export function useChannelSubscription(channel: Channel | null) {
void cleanup();
}
};
}, [channelId, channelType]);
}, [channelId, channelType, queryClient]);
}
export function useSendMessageMutation(

View File

@@ -739,7 +739,9 @@ const MessageTimelineBase = React.forwardRef<
{useTimelineVirtualizer && timelineList ? (
<div
className="h-full min-h-0 w-full"
data-live-message-count={messages.length}
data-render-pending={isRenderPending ? "true" : undefined}
data-rendered-message-count={renderedMessages.length}
>
{timelineList}
</div>
@@ -844,7 +846,7 @@ const MessageTimelineBase = React.forwardRef<
)}
</div>
{!isAtBottom ? (
{!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center px-4",

View File

@@ -91,7 +91,7 @@ type MessageRowItemProps = {
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
};
export function MessageRowItem({
export const MessageRowItem = React.memo(function MessageRowItem({
channelId,
currentPubkey,
entry,
@@ -122,6 +122,13 @@ export function MessageRowItem({
unfollowThreadById,
videoReviewContext,
}: MessageRowItemProps) {
if (import.meta.env.MODE === "e2e" && typeof window !== "undefined") {
const probe = window as unknown as {
__TIMELINE_ROW_RENDER_COUNT__?: number;
};
probe.__TIMELINE_ROW_RENDER_COUNT__ =
(probe.__TIMELINE_ROW_RENDER_COUNT__ ?? 0) + 1;
}
const { message, summary } = entry;
const canManage = canManageMessageForCurrentUser(
message,
@@ -225,4 +232,4 @@ export function MessageRowItem({
{footer}
</div>
);
}
});

View File

@@ -14,11 +14,11 @@ export function nextRetainedTimelineKeys(
const offset = list.scrollOffset;
const indexAt = (target: number) =>
list.findItemIndex(Math.min(list.scrollSize, Math.max(0, target)));
const admissionStart = indexAt(offset - viewportSize * 8);
const admissionEnd = indexAt(offset + viewportSize * 9);
const evictionStart = indexAt(offset - viewportSize * 12);
const evictionEnd = indexAt(offset + viewportSize * 13);
const tailStart = indexAt(list.scrollSize - viewportSize * 3);
const admissionStart = indexAt(offset - viewportSize * 2);
const admissionEnd = indexAt(offset + viewportSize * 2);
const evictionStart = indexAt(offset - viewportSize * 3);
const evictionEnd = indexAt(offset + viewportSize * 3);
const tailStart = indexAt(list.scrollSize - viewportSize);
const next = new Set<string>();
for (let index = evictionStart; index <= evictionEnd; index += 1) {

View File

@@ -67,20 +67,26 @@ it("does not keep the full timeline mounted before the viewport is measured", as
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.length, 8);
assert.equal(retention.retainedIndices[0], 9_992);
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.equal(retention.retainedIndices.length, 51);
assert.ok(retention.retainedIndices.includes(4_980));
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(5_020));
assert.ok(!retention.retainedIndices.includes(4_979));
assert.ok(!retention.retainedIndices.includes(5_021));
assert.ok(retention.retainedIndices.includes(9_990));
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.equal(retention.retainedIndices.length, 51);
assert.ok(retention.retainedIndices.includes(4_980));
assert.ok(retention.retainedIndices.includes(5_000));
assert.ok(retention.retainedIndices.includes(5_020));
assert.ok(retention.retainedIndices.includes(9_990));
assert.ok(retention.retainedIndices.includes(9_999));
await act(async () => root.unmount());

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import type { VListHandle } from "virtua";
import { nextRetainedTimelineKeys } from "./timelineRetention";
const INITIAL_RETAINED_TAIL_SIZE = 100;
const INITIAL_RETAINED_TAIL_SIZE = 8;
export function useTimelineRetention(
keys: readonly string[],

View File

@@ -5139,10 +5139,6 @@ async function handleGetChannelWindow(
return relayQuery(config, [filter]);
};
if (!args.cursor) {
return execute();
}
const probe = window as unknown as {
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
__CHANNEL_WINDOW_INFLIGHT__?: number;
@@ -5151,6 +5147,10 @@ async function handleGetChannelWindow(
probe.__CHANNEL_WINDOW_FETCH_COUNT__ =
(probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1;
if (!args.cursor) {
return execute();
}
const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0;
if (delayMs <= 0) {
return execute();