diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 6257a75b72..7f26a8eee6 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -9,6 +9,7 @@ import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
import { AppHuddleShell } from "@/app/AppHuddleShell";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+import { useDeferredSidebarNavigation } from "@/app/navigation/useDeferredSidebarNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
@@ -86,6 +87,7 @@ import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
+import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types";
@@ -109,7 +111,7 @@ export function AppShell() {
handleHuddleStartPendingChange,
handleHuddleStarted,
handleHuddleVisibilityChange,
- handleSidebarChannelSelect,
+ handleSidebarChannelSelect: selectSidebarChannel,
huddleBackingChannelIds,
revealedHuddleChannelIds,
isHuddleCompanionOpen,
@@ -153,6 +155,15 @@ export function AppShell() {
() => deriveShellRoute(location.pathname),
[location.pathname],
);
+ const {
+ pendingChannelId: pendingSidebarChannelId,
+ selectDeferred: handleSidebarChannelSelect,
+ } = useDeferredSidebarNavigation({
+ pathname: location.pathname,
+ selectedChannelId,
+ selectChannel: selectSidebarChannel,
+ });
+ const sidebarSelectedChannelId = pendingSidebarChannelId ?? selectedChannelId;
const {
removeCommunity: handleRemoveCommunity,
switchCommunity: handleSwitchCommunity,
@@ -880,8 +891,10 @@ export function AppShell() {
] ?? undefined)
: undefined
}
- selectedChannelId={selectedChannelId}
- selectedView={selectedView}
+ selectedChannelId={sidebarSelectedChannelId}
+ selectedView={
+ pendingSidebarChannelId ? "channel" : selectedView
+ }
unreadChannelIds={unreadChannelIds}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
@@ -899,7 +912,16 @@ export function AppShell() {
mainInsetRef={mainInsetRef}
terminal={}
>
-
+ {pendingSidebarChannelId ? (
+
+
+
+ ) : (
+
+ )}
{!isHuddleRoom ? (
{
if (canGoBack) {
+ dispatchNavigationIntent();
router.history.back();
return;
}
@@ -282,6 +285,7 @@ export function useAppNavigation() {
const closeWorkflowDetail = React.useCallback(() => {
if (canGoBack) {
+ dispatchNavigationIntent();
router.history.back();
return;
}
@@ -292,6 +296,7 @@ export function useAppNavigation() {
const closeForumPost = React.useCallback(
(channelId: string) => {
if (canGoBack) {
+ dispatchNavigationIntent();
router.history.back();
return;
}
diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts
index e5513247d5..0fd6bc14e5 100644
--- a/desktop/src/app/navigation/useBackForwardControls.ts
+++ b/desktop/src/app/navigation/useBackForwardControls.ts
@@ -7,6 +7,7 @@ import {
import { isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
+import { dispatchNavigationIntent } from "@/app/navigation/navigationIntent";
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
@@ -59,6 +60,7 @@ export function useBackForwardControls() {
return;
}
+ dispatchNavigationIntent();
router.history.back();
}, [canGoBack, router.history]);
@@ -67,6 +69,7 @@ export function useBackForwardControls() {
return;
}
+ dispatchNavigationIntent();
router.history.forward();
}, [canGoForward, router.history]);
diff --git a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts
new file mode 100644
index 0000000000..e1f5d8faa7
--- /dev/null
+++ b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts
@@ -0,0 +1,134 @@
+import * as React from "react";
+
+import { dispatchNavigationIntent } from "@/app/navigation/navigationIntent";
+
+type DeferredSidebarNavigationOptions = {
+ pathname: string;
+ selectedChannelId: string | null;
+ selectChannel: (channelId: string) => Promise | undefined;
+};
+
+export function useDeferredSidebarNavigation({
+ pathname,
+ selectedChannelId,
+ selectChannel,
+}: DeferredSidebarNavigationOptions) {
+ const [pendingChannelId, setPendingChannelId] = React.useState(
+ null,
+ );
+ const frameRef = React.useRef(null);
+ const timerRef = React.useRef(null);
+ const generationRef = React.useRef(0);
+ const isCommittingRef = React.useRef(false);
+ const ignoreNextNavigationIntentRef = React.useRef(false);
+ const pathnameRef = React.useRef(pathname);
+ pathnameRef.current = pathname;
+
+ const cancelDeferred = React.useCallback(() => {
+ generationRef.current += 1;
+ if (frameRef.current !== null) {
+ window.cancelAnimationFrame(frameRef.current);
+ frameRef.current = null;
+ }
+ if (timerRef.current !== null) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ const cancel = React.useCallback(() => {
+ isCommittingRef.current = false;
+ ignoreNextNavigationIntentRef.current = false;
+ cancelDeferred();
+ setPendingChannelId(null);
+ }, [cancelDeferred]);
+
+ React.useEffect(() => cancel, [cancel]);
+ React.useEffect(() => {
+ const handleNavigationIntent = () => {
+ if (ignoreNextNavigationIntentRef.current) {
+ ignoreNextNavigationIntentRef.current = false;
+ return;
+ }
+ cancel();
+ };
+ window.addEventListener("buzz:navigation-intent", handleNavigationIntent);
+ return () =>
+ window.removeEventListener(
+ "buzz:navigation-intent",
+ handleNavigationIntent,
+ );
+ }, [cancel]);
+ React.useEffect(() => {
+ // Keep the destination skeleton mounted through the route commit and one
+ // paint. Clearing state directly in this effect can be batched before the
+ // browser paints, exposing either the old outlet or expensive new outlet.
+ void pathname;
+ frameRef.current = window.requestAnimationFrame(() => {
+ frameRef.current = null;
+ cancel();
+ });
+ }, [cancel, pathname]);
+ React.useEffect(() => {
+ if (!isCommittingRef.current && pendingChannelId === selectedChannelId) {
+ setPendingChannelId(null);
+ }
+ }, [pendingChannelId, selectedChannelId]);
+
+ const selectDeferred = React.useCallback(
+ (channelId: string) => {
+ if (channelId === selectedChannelId) {
+ if (pendingChannelId !== null) {
+ dispatchNavigationIntent();
+ cancel();
+ }
+ const navigationResult = selectChannel(channelId);
+ if (navigationResult) void navigationResult.catch(() => undefined);
+ return;
+ }
+ dispatchNavigationIntent();
+ cancel();
+ const generation = generationRef.current;
+ const sourcePathname = pathnameRef.current;
+ setPendingChannelId(channelId);
+ frameRef.current = window.requestAnimationFrame(() => {
+ frameRef.current = null;
+ if (
+ generationRef.current !== generation ||
+ pathnameRef.current !== sourcePathname
+ ) {
+ return;
+ }
+ frameRef.current = window.requestAnimationFrame(() => {
+ frameRef.current = null;
+ if (
+ generationRef.current !== generation ||
+ pathnameRef.current !== sourcePathname
+ ) {
+ return;
+ }
+ timerRef.current = window.setTimeout(() => {
+ timerRef.current = null;
+ if (
+ generationRef.current !== generation ||
+ pathnameRef.current !== sourcePathname
+ ) {
+ return;
+ }
+ isCommittingRef.current = true;
+ ignoreNextNavigationIntentRef.current = true;
+ const navigationResult = selectChannel(channelId);
+ if (navigationResult) {
+ void navigationResult.catch(() => {
+ if (generationRef.current === generation) cancel();
+ });
+ }
+ }, 0);
+ });
+ });
+ },
+ [cancel, pendingChannelId, selectChannel, selectedChannelId],
+ );
+
+ return { pendingChannelId, selectDeferred };
+}
diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts
index a82916d044..475f8f9323 100644
--- a/desktop/src/app/useHuddlePresentation.ts
+++ b/desktop/src/app/useHuddlePresentation.ts
@@ -298,7 +298,7 @@ export function useHuddlePresentation() {
void queryClient.invalidateQueries({
queryKey: channelWindowKey(ephemeralChannelId),
});
- void goChannel(ephemeralChannelId);
+ return goChannel(ephemeralChannelId);
},
[goChannel, queryClient, revealHuddleChannel],
);
@@ -306,7 +306,7 @@ export function useHuddlePresentation() {
(ephemeralChannelId: string) => {
activeHuddleChannelIdRef.current = ephemeralChannelId;
trackHuddleBackingChannel(ephemeralChannelId);
- viewHuddleChannel(ephemeralChannelId);
+ return viewHuddleChannel(ephemeralChannelId);
},
[trackHuddleBackingChannel, viewHuddleChannel],
);
@@ -316,10 +316,9 @@ export function useHuddlePresentation() {
isHuddleDrawerOpen &&
channelId === activeHuddleChannelIdRef.current
) {
- showHuddleInMainApp(channelId);
- return;
+ return showHuddleInMainApp(channelId);
}
- void goChannel(channelId);
+ return goChannel(channelId);
},
[goChannel, isHuddleDrawerOpen, showHuddleInMainApp],
);
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 1ec6cee95e..7f4632b13e 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -568,6 +568,7 @@ export const ChannelPane = React.memo(function ChannelPane({
>
{isHuddleTranscript ? null : header}
+ ) : shouldShowThreadSkeleton ? (
+ (() => {
+ if (isHuddleTranscript) {
+ return wrapThreadPanel();
+ }
+ const panel = (
+
+ );
+ return wrapThreadPanel(panel);
+ })()
) : threadHeadMessage ? (
(() => {
const panel = (
@@ -853,20 +868,6 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapThreadPanel(panel);
})()
- ) : shouldShowThreadSkeleton ? (
- (() => {
- if (isHuddleTranscript) {
- return wrapThreadPanel();
- }
- const panel = (
-
- );
- return wrapThreadPanel(panel);
- })()
) : activeChannel && selectedAgent ? (
(() => {
// When the panel was opened from a different channel than the
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 6254afd8c7..361f2b3588 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -157,6 +157,9 @@ export function ChannelScreen({
const [editTargetId, setEditTargetId] = React.useState(null);
const [optimisticOpenThreadHeadId, setOptimisticOpenThreadHeadId] =
React.useState(undefined);
+ const [pendingOpenThreadHeadId, setPendingOpenThreadHeadId] = React.useState<
+ string | null
+ >(null);
const clearOptimisticThreadOverride = React.useCallback(() => {
setOptimisticOpenThreadHeadId(undefined);
}, []);
@@ -171,6 +174,11 @@ export function ChannelScreen({
openThreadHeadId,
optimisticOpenThreadHeadId,
});
+ React.useEffect(() => {
+ if (pendingOpenThreadHeadId === effectiveOpenThreadHeadId) {
+ setPendingOpenThreadHeadId(null);
+ }
+ }, [effectiveOpenThreadHeadId, pendingOpenThreadHeadId]);
const isNotifiedForEffectiveThread =
effectiveOpenThreadHeadId != null
? isNotifiedForThread(effectiveOpenThreadHeadId)
@@ -489,6 +497,7 @@ export function ChannelScreen({
recordThreadInteraction,
openThreadHeadId: effectiveOpenThreadHeadId,
onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
+ onPendingOpenThreadHeadIdChange: setPendingOpenThreadHeadId,
onRequestEmptyEditDelete: setEmptyDeleteId,
sendMessageMutation,
setExpandedThreadReplyIds,
@@ -605,6 +614,7 @@ export function ChannelScreen({
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
+ hasAuthoritativePage: (windowQuery.data?.pages.length ?? 0) > 0,
dataLength: messagesQuery.data?.length ?? null,
},
hasSettledThisChannel,
@@ -682,7 +692,10 @@ export function ChannelScreen({
? threadFirstUnreadReplyId
: null;
const shouldShowThreadSkeleton = Boolean(
- effectiveOpenThreadHeadId && activeChannel && !displayedThreadHeadMessage,
+ pendingOpenThreadHeadId ||
+ (effectiveOpenThreadHeadId &&
+ activeChannel &&
+ !displayedThreadHeadMessage),
);
const isNarrowPanelViewport =
channelContentWidthPx > 0 &&
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index f9c57f6656..39a09f475d 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -31,6 +31,7 @@ export function useChannelPaneHandlers({
profiles,
recordThreadInteraction,
onOptimisticOpenThreadHeadIdChange,
+ onPendingOpenThreadHeadIdChange,
onRequestEmptyEditDelete,
openThreadHeadId,
sendMessageMutation,
@@ -54,6 +55,7 @@ export function useChannelPaneHandlers({
onOptimisticOpenThreadHeadIdChange: React.Dispatch<
React.SetStateAction
>;
+ onPendingOpenThreadHeadIdChange: (value: string | null) => void;
onRequestEmptyEditDelete: (eventId: string) => void;
openThreadHeadId: string | null;
sendMessageMutation: ReturnType;
@@ -107,17 +109,70 @@ export function useChannelPaneHandlers({
const markRevealedRepliesReadRef = React.useRef(markRevealedRepliesRead);
markRevealedRepliesReadRef.current = markRevealedRepliesRead;
+ const deferredPanelFrameRef = React.useRef(null);
+ const deferredPanelTimerRef = React.useRef(null);
+ const deferredPanelGenerationRef = React.useRef(0);
+ const intendedOpenThreadHeadIdRef = React.useRef(openThreadHeadId);
+ if (
+ deferredPanelFrameRef.current === null &&
+ deferredPanelTimerRef.current === null
+ ) {
+ intendedOpenThreadHeadIdRef.current = openThreadHeadId;
+ }
const deferPanelState = React.useCallback((update: () => void) => {
- window.setTimeout(() => {
- React.startTransition(update);
- }, 0);
+ deferredPanelGenerationRef.current += 1;
+ const generation = deferredPanelGenerationRef.current;
+ if (deferredPanelFrameRef.current !== null) {
+ window.cancelAnimationFrame(deferredPanelFrameRef.current);
+ }
+ if (deferredPanelTimerRef.current !== null) {
+ window.clearTimeout(deferredPanelTimerRef.current);
+ }
+ deferredPanelFrameRef.current = window.requestAnimationFrame(() => {
+ if (deferredPanelGenerationRef.current !== generation) return;
+ deferredPanelFrameRef.current = window.requestAnimationFrame(() => {
+ if (deferredPanelGenerationRef.current !== generation) return;
+ deferredPanelFrameRef.current = null;
+ deferredPanelTimerRef.current = window.setTimeout(() => {
+ if (deferredPanelGenerationRef.current !== generation) return;
+ deferredPanelTimerRef.current = null;
+ React.startTransition(update);
+ }, 0);
+ });
+ });
}, []);
+ React.useEffect(() => {
+ const cancelDeferredPanelState = () => {
+ deferredPanelGenerationRef.current += 1;
+ intendedOpenThreadHeadIdRef.current = openThreadHeadIdRef.current;
+ onPendingOpenThreadHeadIdChange(null);
+ onOptimisticOpenThreadHeadIdChange(undefined);
+ if (deferredPanelFrameRef.current !== null) {
+ window.cancelAnimationFrame(deferredPanelFrameRef.current);
+ deferredPanelFrameRef.current = null;
+ }
+ if (deferredPanelTimerRef.current !== null) {
+ window.clearTimeout(deferredPanelTimerRef.current);
+ deferredPanelTimerRef.current = null;
+ }
+ };
+ window.addEventListener("buzz:navigation-intent", cancelDeferredPanelState);
+ return () => {
+ window.removeEventListener(
+ "buzz:navigation-intent",
+ cancelDeferredPanelState,
+ );
+ cancelDeferredPanelState();
+ };
+ }, [onOptimisticOpenThreadHeadIdChange, onPendingOpenThreadHeadIdChange]);
const handleCancelThreadReply = React.useCallback(() => {
setThreadReplyTargetId(openThreadHeadIdRef.current);
}, [setThreadReplyTargetId]);
const handleCloseThread = React.useCallback(() => {
+ intendedOpenThreadHeadIdRef.current = null;
+ onPendingOpenThreadHeadIdChange(null);
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
@@ -128,6 +183,7 @@ export function useChannelPaneHandlers({
}, [
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
+ onPendingOpenThreadHeadIdChange,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setThreadReplyTargetId,
@@ -198,7 +254,9 @@ export function useChannelPaneHandlers({
const handleOpenThread = React.useCallback(
(message: { id: string }) => {
- if (openThreadHeadIdRef.current === message.id) {
+ if (intendedOpenThreadHeadIdRef.current === message.id) {
+ intendedOpenThreadHeadIdRef.current = null;
+ onPendingOpenThreadHeadIdChange(null);
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
@@ -210,6 +268,8 @@ export function useChannelPaneHandlers({
return;
}
+ intendedOpenThreadHeadIdRef.current = message.id;
+ onPendingOpenThreadHeadIdChange(message.id);
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(message.id);
setOpenThreadHeadId(message.id);
@@ -222,6 +282,7 @@ export function useChannelPaneHandlers({
[
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
+ onPendingOpenThreadHeadIdChange,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 8b457a7adf..c30f98a3c2 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -55,6 +55,8 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
// from the on-render overlay.
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
import {
+ appendOlderChannelWindow,
+ compareRelayOrder,
emptyChannelWindowStore,
mapChannelWindowEvents,
mergeLiveChannelWindowEvent,
@@ -226,6 +228,9 @@ 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();
const queryKey = channelWindowKey(channel?.id ?? "none");
@@ -236,6 +241,9 @@ export function useChannelWindowQuery(channel: Channel | null) {
queryClient.getQueryData(queryKey) ??
emptyChannelWindowStore(),
staleTime: Number.POSITIVE_INFINITY,
+ // Page provenance determines whether cached rows (including known-empty)
+ // are warm enough to paint. Retain it for exactly as long as those rows.
+ gcTime: CHANNEL_TIMELINE_GC_TIME_MS,
});
}
@@ -245,21 +253,97 @@ export function reconcileFetchedChannelWindow(
events: Awaited>,
previousMessages: RelayEvent[],
signal: AbortSignal,
+): RelayEvent[] {
+ return reconcileFetchedChannelWindowPages(
+ queryClient,
+ channelId,
+ [parseChannelWindowResponse(events, channelId, null)],
+ previousMessages,
+ signal,
+ );
+}
+
+export function reconcileFetchedChannelWindowPages(
+ queryClient: QueryClient,
+ channelId: string,
+ pages: ReturnType[],
+ previousMessages: RelayEvent[],
+ signal: AbortSignal,
): RelayEvent[] {
// Tauri invokes cannot be canceled after dispatch. A replacement refetch can
// therefore win while this older request is still in flight. Never let that
// canceled request commit its stale page into the authoritative window.
signal.throwIfAborted();
const windowKey = channelWindowKey(channelId);
- const page = parseChannelWindowResponse(events, channelId, null);
const current =
queryClient.getQueryData(windowKey) ??
emptyChannelWindowStore();
- const next = replaceNewestChannelWindow(current, page);
+ let next = replaceNewestChannelWindow(current, pages[0]);
+ for (const page of pages.slice(1)) {
+ next = appendOlderChannelWindow(next, page);
+ }
queryClient.setQueryData(windowKey, next);
return reconcileChannelWindowMessages(next, previousMessages);
}
+const CHANNEL_WINDOW_PAGE_SIZE = 50;
+const CHANNEL_WINDOW_MAX_REQUEST_ROWS = 200;
+
+async function getRefreshedChannelWindowPages(
+ channelId: string,
+ retainedWindow: ChannelWindowStore | undefined,
+) {
+ const retainedRowCount =
+ retainedWindow?.pages.reduce(
+ (count, page) => count + page.rows.length,
+ 0,
+ ) ?? 0;
+ const retainedOldest = retainedWindow?.pages.at(-1)?.rows.at(-1)?.event;
+ const targetRows = Math.max(CHANNEL_WINDOW_PAGE_SIZE, retainedRowCount);
+ const firstEvents = await getChannelWindowEvents(
+ channelId,
+ null,
+ Math.min(targetRows, CHANNEL_WINDOW_MAX_REQUEST_ROWS),
+ );
+ const firstPage = parseChannelWindowResponse(firstEvents, channelId, null);
+ const pages = [firstPage];
+ let rowCount = firstPage.rows.length;
+
+ const coversRetainedOldest = () => {
+ if (!retainedOldest) return true;
+ const refreshedOldest = pages.at(-1)?.rows.at(-1)?.event;
+ return (
+ refreshedOldest !== undefined &&
+ compareRelayOrder(refreshedOldest, retainedOldest) >= 0
+ );
+ };
+
+ while (
+ pages.at(-1)?.hasMore &&
+ (rowCount < targetRows || !coversRetainedOldest())
+ ) {
+ const tail = pages.at(-1);
+ if (!tail?.nextCursor) break;
+ const nextEvents = await getChannelWindowEvents(
+ channelId,
+ tail.nextCursor,
+ Math.min(
+ Math.max(CHANNEL_WINDOW_PAGE_SIZE, targetRows - rowCount),
+ CHANNEL_WINDOW_MAX_REQUEST_ROWS,
+ ),
+ );
+ const nextPage = parseChannelWindowResponse(
+ nextEvents,
+ channelId,
+ tail.nextCursor,
+ );
+ pages.push(nextPage);
+ rowCount += nextPage.rows.length;
+ }
+
+ return pages;
+}
+
export function useChannelMessagesQuery(channel: Channel | null) {
const queryClient = useQueryClient();
const queryKey = channelMessagesKey(channel?.id ?? "none");
@@ -271,17 +355,26 @@ export function useChannelMessagesQuery(channel: Channel | null) {
if (!channel) throw new Error("No channel selected.");
const previousMessages =
queryClient.getQueryData(queryKey) ?? [];
- const events = await getChannelWindowEvents(channel.id);
- return reconcileFetchedChannelWindow(
+ const retainedWindow = queryClient.getQueryData(
+ channelWindowKey(channel.id),
+ );
+ // A subscription/reconnect catch-up must cover the retained page extent.
+ // Refetching only the default head page would replace a multi-page window
+ // and delete the rows (and anchor) the reader is currently parked on.
+ const pages = await getRefreshedChannelWindowPages(
+ channel.id,
+ retainedWindow,
+ );
+ return reconcileFetchedChannelWindowPages(
queryClient,
channel.id,
- events,
+ pages,
previousMessages,
signal,
);
},
- staleTime: 5 * 60 * 1_000,
- gcTime: 60 * 60 * 1_000,
+ staleTime: CHANNEL_TIMELINE_STALE_TIME_MS,
+ gcTime: CHANNEL_TIMELINE_GC_TIME_MS,
});
}
@@ -406,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(
+ 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);
@@ -431,7 +532,7 @@ export function useChannelSubscription(channel: Channel | null) {
void cleanup();
}
};
- }, [channelId, channelType]);
+ }, [channelId, channelType, queryClient]);
}
export function useSendMessageMutation(
diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs
index 14ec110add..97b00f4518 100644
--- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs
+++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
-import { reconcileFetchedChannelWindow } from "../hooks.ts";
+import {
+ CHANNEL_TIMELINE_GC_TIME_MS,
+ reconcileFetchedChannelWindow,
+ reconcileFetchedChannelWindowPages,
+} from "../hooks.ts";
import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts";
import {
appendOlderChannelWindow,
@@ -51,6 +55,10 @@ function newestPage(rows) {
};
}
+test("channel page provenance is retained for the full warm-row lifetime", () => {
+ assert.equal(CHANNEL_TIMELINE_GC_TIME_MS, 60 * 60 * 1_000);
+});
+
function createHarness() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -287,6 +295,61 @@ test("test_live_projection_retains_pending_send_and_non_broadcast_thread_reply",
]);
});
+test("test_catch_up_atomically_replaces_the_full_retained_extent", () => {
+ const harness = createHarness();
+ const older = event("older", 50);
+ const firstCursor = { createdAt: 100, eventId: event("initial", 100).id };
+ const loaded = appendOlderChannelWindow(
+ replaceNewestChannelWindow(emptyChannelWindowStore(), {
+ ...newestPage([event("initial", 100)]),
+ nextCursor: firstCursor,
+ hasMore: true,
+ }),
+ {
+ startCursor: firstCursor,
+ rows: [{ event: older, thread: null }],
+ aux: [],
+ nextCursor: null,
+ hasMore: false,
+ },
+ );
+ harness.client.setQueryData(harness.windowKey, loaded);
+ harness.client.setQueryData(harness.messagesKey, [
+ older,
+ event("initial", 100),
+ ]);
+
+ const refreshedHead = {
+ startCursor: null,
+ rows: [event("gap", 110), event("initial", 100)].map((item) => ({
+ event: item,
+ thread: null,
+ })),
+ aux: [],
+ nextCursor: firstCursor,
+ hasMore: true,
+ };
+ const refreshedTail = {
+ startCursor: firstCursor,
+ rows: [{ event: older, thread: null }],
+ aux: [],
+ nextCursor: null,
+ hasMore: false,
+ };
+
+ const projected = reconcileFetchedChannelWindowPages(
+ harness.client,
+ harness.channelId,
+ [refreshedHead, refreshedTail],
+ harness.client.getQueryData(harness.messagesKey),
+ new AbortController().signal,
+ );
+ harness.client.setQueryData(harness.messagesKey, projected);
+
+ assert.equal(harness.client.getQueryData(harness.windowKey).pages.length, 2);
+ assert.deepEqual(contents(harness), ["older", "initial", "gap"]);
+});
+
test("test_canceled_stale_fetch_cannot_overwrite_catch_up_window", async () => {
const harness = createHarness();
const requests = [];
diff --git a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs
index fe6960f74c..41f924833e 100644
--- a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs
+++ b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs
@@ -31,6 +31,33 @@ test("stale placeholder while refetching is loading", () => {
);
});
+test("authoritative warm empty stays visible while revalidating", () => {
+ assert.equal(
+ selectTimelineLoadingState({
+ ...settled,
+ isFetching: true,
+ hasAuthoritativePage: true,
+ dataLength: 0,
+ }),
+ false,
+ );
+});
+
+test("authoritative warm rows stay visible before the local latch settles", () => {
+ assert.equal(
+ selectTimelineLoadingState(
+ {
+ ...settled,
+ isFetching: true,
+ hasAuthoritativePage: true,
+ dataLength: 12,
+ },
+ false,
+ ),
+ false,
+ );
+});
+
test("subscription-seeded empty cache while fetching is loading", () => {
// The live subscription's setQueryData seeds [] before history settles, so
// data is defined but empty and a fetch is still in flight.
diff --git a/desktop/src/features/messages/lib/timelineLoadingState.ts b/desktop/src/features/messages/lib/timelineLoadingState.ts
index ea46168d25..1a4df46052 100644
--- a/desktop/src/features/messages/lib/timelineLoadingState.ts
+++ b/desktop/src/features/messages/lib/timelineLoadingState.ts
@@ -12,6 +12,8 @@ export type TimelineQueryStatus = {
isPending: boolean;
isFetching: boolean;
isPlaceholderData: boolean;
+ /** True once this channel has an authoritative page, including an empty one. */
+ hasAuthoritativePage?: boolean;
dataLength: number | null;
};
@@ -19,6 +21,12 @@ export function selectTimelineLoadingState(
status: TimelineQueryStatus,
hasSettled = true,
): boolean {
+ // Page provenance, not row count, distinguishes a warm empty channel from a
+ // cold cache seeded by the live subscription. Once a page exists, every
+ // subsequent fetch is background revalidation and must not cover the cache.
+ if (status.hasAuthoritativePage) {
+ return false;
+ }
if (status.isPending) {
return true;
}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index f8c5395b17..7e730d4cc9 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -146,12 +146,6 @@ type TimelineSnapshot = {
historyExhausted: boolean;
};
-const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = {
- channelId: null,
- messages: EMPTY_MESSAGES,
- historyExhausted: false,
-};
-
const MessageTimelineBase = React.forwardRef<
MessageTimelineHandle,
MessageTimelineProps
@@ -232,28 +226,16 @@ const MessageTimelineBase = React.forwardRef<
[scrollContainerRef, virtualizerScrollParent],
);
- // Gate the heavy timeline render (each row runs a synchronous
- // react-markdown parse) behind React concurrency. `useDeferredValue` lets the
- // commit that rebuilds the message list yield to higher-priority work, so the
- // main thread stops freezing and the OS no longer shows the busy cursor when
- // entering a channel. We pass `initialValue: []` so even the FIRST render on
- // channel entry stays light — the heavy list streams in on a deferred commit
- // rather than blocking the initial paint. We deliberately drive BOTH the
- // scroll manager and the rendered list off the same deferred value —
- // scroll/autoscroll/deep-link logic reads the DOM (`scrollIntoView`,
- // ResizeObserver on the content), so it must stay consistent with what's
- // actually painted. You can't scroll to a row that hasn't committed yet.
- // Channel id travels with the deferred message snapshot. Without that guard, a
- // route change can paint the previous channel's deferred rows for a frame even
- // though the sidebar/header already moved to the new channel.
+ // The timeline itself is keyed by channel, so this is the selected channel's
+ // route-matched cache on mount. Warm channels paint it immediately; cold
+ // channels still initialize cheaply because their snapshot is empty. Keeping
+ // rows and history provenance in the same initial value also prevents scroll
+ // and deep-link logic from observing a different generation than the DOM.
const liveSnapshot = React.useMemo(
() => ({ channelId: channelId ?? null, messages, historyExhausted }),
[channelId, historyExhausted, messages],
);
- const deferredSnapshot = React.useDeferredValue(
- liveSnapshot,
- EMPTY_TIMELINE_SNAPSHOT,
- );
+ const deferredSnapshot = React.useDeferredValue(liveSnapshot, liveSnapshot);
const deferredMessages = deferredSnapshot.messages;
const imagePreloadStateRef = React.useRef({
activeImages: new Set(),
@@ -283,7 +265,10 @@ const MessageTimelineBase = React.forwardRef<
if (!useTimelineVirtualizer) {
setVirtualizerScrollParent(scrollContainerRef.current);
}
- setTimelineVirtualizerApi(null);
+ // Do not clear the child-registered virtualizer API here. This parent
+ // layout effect runs after child layout effects on a keyed channel mount;
+ // clearing it would leave target navigation waiting forever for APIs that
+ // the still-mounted child will not register again.
}, [scrollContainerRef, scrollContainerDomKey]);
const hasPersistentIntro =
@@ -754,7 +739,9 @@ const MessageTimelineBase = React.forwardRef<
{useTimelineVirtualizer && timelineList ? (
{timelineList}
@@ -859,7 +846,7 @@ const MessageTimelineBase = React.forwardRef<
)}
- {!isAtBottom ? (
+ {!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
;
};
-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}
);
-}
+});
diff --git a/desktop/src/features/messages/ui/timelineRetention.ts b/desktop/src/features/messages/ui/timelineRetention.ts
index d1c50a37af..9620a6254f 100644
--- a/desktop/src/features/messages/ui/timelineRetention.ts
+++ b/desktop/src/features/messages/ui/timelineRetention.ts
@@ -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();
for (let index = evictionStart; index <= evictionEnd; index += 1) {
diff --git a/desktop/src/features/messages/ui/timelineSnapshotProjection.test.mjs b/desktop/src/features/messages/ui/timelineSnapshotProjection.test.mjs
index cd43c1de35..021a07d6d9 100644
--- a/desktop/src/features/messages/ui/timelineSnapshotProjection.test.mjs
+++ b/desktop/src/features/messages/ui/timelineSnapshotProjection.test.mjs
@@ -219,7 +219,7 @@ function samePair(a, b) {
function makeHarness(records, scroller) {
return function Harness({ snapshot }) {
- const deferredSnapshot = React.useDeferredValue(snapshot, EMPTY_SNAPSHOT);
+ const deferredSnapshot = React.useDeferredValue(snapshot, snapshot);
const buffered = useBufferedTimelineMessages({
channelId: deferredSnapshot.channelId,
isAtBottom: false, // reader is scrolled up — the tear's regime
@@ -272,6 +272,27 @@ async function mount(Comp, snapshot) {
// ── Tests ────────────────────────────────────────────────────────────────────
+test("warm snapshot is the first committed render, including its provenance", async () => {
+ const warm = {
+ channelId: "chan-warm",
+ messages: rows(["cached-a", "cached-b"]),
+ historyExhausted: true,
+ };
+ const records = [];
+ const handle = await mount(makeHarness(records, makeFakeScroller()), warm);
+
+ assert.deepEqual(
+ {
+ count: records[0].count,
+ firstId: records[0].firstId,
+ exhausted: records[0].exhausted,
+ },
+ { count: 2, firstId: "cached-a", exhausted: true },
+ );
+
+ await handle.unmount();
+});
+
test("pass-1 landing: exhaustion proof can never pair with the stale row array", async () => {
const CHANNEL = "chan-tear";
// Snapshot A: 100 rows loaded, more history exists (mid-pagination).
diff --git a/desktop/src/features/messages/ui/useTimelineRetention.test.mjs b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs
index d965e19010..5f8449763a 100644
--- a/desktop/src/features/messages/ui/useTimelineRetention.test.mjs
+++ b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs
@@ -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());
diff --git a/desktop/src/features/messages/ui/useTimelineRetention.ts b/desktop/src/features/messages/ui/useTimelineRetention.ts
index 05336d4a13..f1af7f7d7c 100644
--- a/desktop/src/features/messages/ui/useTimelineRetention.ts
+++ b/desktop/src/features/messages/ui/useTimelineRetention.ts
@@ -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[],
diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
index 4a618fcf0a..775c7387d7 100644
--- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
@@ -105,6 +105,7 @@ export function AppSidebarPrimaryMenu({
{
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ await page.evaluate(() => {
+ const sourceHref = window.location.href;
+ const observed = {
+ selectedBeforeRoute: false,
+ singularSelection: false,
+ activeBackgroundTransitions: true,
+ destinationSkeletonBeforeRoute: false,
+ destinationSkeletonAfterRoute: false,
+ destinationContentAfterSkeleton: false,
+ };
+ Object.assign(window, { __BUZZ_SIDEBAR_SELECTION_OBSERVED__: observed });
+ const originalPushState = window.history.pushState.bind(window.history);
+ window.history.pushState = (...args) => {
+ observed.destinationSkeletonAfterRoute = Boolean(
+ document.querySelector('[data-testid="pending-channel-skeleton"]'),
+ );
+ return originalPushState(...args);
+ };
+ const routeObserver = new MutationObserver(() => {
+ const skeleton = document.querySelector(
+ '[data-testid="pending-channel-skeleton"]',
+ );
+ const title = document.querySelector('[data-testid="chat-title"]');
+ if (
+ observed.destinationSkeletonAfterRoute &&
+ !skeleton &&
+ title?.textContent === "random"
+ ) {
+ observed.destinationContentAfterSkeleton = true;
+ routeObserver.disconnect();
+ }
+ });
+ routeObserver.observe(document.body, { childList: true, subtree: true });
+ const observer = new MutationObserver(() => {
+ const general = document.querySelector('[data-testid="channel-general"]');
+ const random = document.querySelector('[data-testid="channel-random"]');
+ if (random?.getAttribute("data-active") !== "true") return;
+ observer.disconnect();
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ observed.selectedBeforeRoute = window.location.href === sourceHref;
+ observed.destinationSkeletonBeforeRoute = Boolean(
+ document.querySelector('[data-testid="pending-channel-skeleton"]'),
+ );
+ observed.singularSelection =
+ general?.getAttribute("data-active") === "false" &&
+ document.querySelectorAll(
+ '[data-testid^="channel-"][data-active="true"]',
+ ).length === 1;
+ observed.activeBackgroundTransitions = [general, random].some(
+ (element) =>
+ element instanceof HTMLElement &&
+ getComputedStyle(element)
+ .transitionProperty.split(",")
+ .map((property) => property.trim())
+ .includes("background-color"),
+ );
+ });
+ });
+ });
+ observer.observe(document.body, {
+ attributes: true,
+ attributeFilter: ["data-active"],
+ childList: true,
+ subtree: true,
+ });
+ });
+
+ await page.getByTestId("channel-random").click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_SIDEBAR_SELECTION_OBSERVED__?: {
+ selectedBeforeRoute: boolean;
+ singularSelection: boolean;
+ activeBackgroundTransitions: boolean;
+ destinationSkeletonBeforeRoute: boolean;
+ destinationSkeletonAfterRoute: boolean;
+ destinationContentAfterSkeleton: boolean;
+ };
+ }
+ ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__ ?? null,
+ ),
+ )
+ .toEqual({
+ selectedBeforeRoute: true,
+ singularSelection: true,
+ activeBackgroundTransitions: false,
+ destinationSkeletonBeforeRoute: true,
+ destinationSkeletonAfterRoute: true,
+ destinationContentAfterSkeleton: true,
+ });
+ await expect(page.getByTestId("pending-channel-skeleton")).toHaveCount(0);
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+});
+
+test("reselecting the current channel cancels pending sidebar navigation", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const generalUrl = page.url();
+ const historyLength = await page.evaluate(() => window.history.length);
+ await page.evaluate(() => {
+ document
+ .querySelector('[data-testid="channel-random"]')
+ ?.click();
+ document
+ .querySelector('[data-testid="channel-general"]')
+ ?.click();
+ });
+
+ await expect(page.getByTestId("pending-channel-skeleton")).toHaveCount(0);
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await page.waitForTimeout(100);
+ await expect(page).toHaveURL(generalUrl);
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
+test("superseded sidebar feedback cannot override newer navigation", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ await page.evaluate(() => {
+ document
+ .querySelector('[data-testid="channel-random"]')
+ ?.click();
+ const inboxButton = document.querySelector(
+ '[data-testid="open-home-view"]',
+ );
+ if (!inboxButton) throw new Error("Expected Inbox button");
+ inboxButton.click();
+ });
+
+ await expect(page.getByTestId("home-inbox")).toBeVisible();
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("home-inbox")).toBeVisible();
+ await expect(page.getByTestId("channel-random")).toHaveAttribute(
+ "data-active",
+ "false",
+ );
+});
+
+test("back navigation supersedes pending sidebar navigation", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const historyLength = await page.evaluate(() => window.history.length);
+ await page.evaluate(() => {
+ document
+ .querySelector('[data-testid="channel-random"]')
+ ?.click();
+ document
+ .querySelector('[data-testid="global-back"]')
+ ?.click();
+ });
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByTestId("home-inbox")).toBeVisible();
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("home-inbox")).toBeVisible();
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
+test("forward navigation supersedes pending sidebar navigation", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await page.getByTestId("channel-random").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await page.getByTestId("global-back").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const historyLength = await page.evaluate(() => window.history.length);
+ await page.evaluate(() => {
+ document
+ .querySelector('[data-testid="channel-watercooler"]')
+ ?.click();
+ document
+ .querySelector('[data-testid="global-forward"]')
+ ?.click();
+ });
+
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await expect(page).toHaveURL(/#\/channels\/[^?]+$/);
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
+test("thread open paints immediate feedback before deferred panel work", async ({
+ page,
+}) => {
+ await installMockBridge(page, { threadRepliesDelayMs: 800 });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const rootMessage = page
+ .getByTestId("message-timeline")
+ .getByTestId("message-row")
+ .first();
+ await rootMessage.hover();
+ await page.evaluate(() => {
+ const observed = { loading: false };
+ Object.assign(window, { __BUZZ_THREAD_OPEN_OBSERVED__: observed });
+ const observer = new MutationObserver(() => {
+ if (!document.querySelector('[data-testid="message-thread-loading"]')) {
+ return;
+ }
+ observer.disconnect();
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ observed.loading =
+ document.querySelector('[data-testid="message-thread-loading"]') !==
+ null;
+ });
+ });
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ });
+
+ await rootMessage.getByRole("button", { name: "Reply" }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_THREAD_OPEN_OBSERVED__?: { loading: boolean };
+ }
+ ).__BUZZ_THREAD_OPEN_OBSERVED__?.loading ?? false,
+ ),
+ )
+ .toBe(true);
+ await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+});
+
+test("rapid thread intents commit only the latest history entry", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const timeline = page.getByTestId("message-timeline");
+ const firstRoot = timeline.locator(
+ '[data-message-id="mock-general-welcome"]',
+ );
+ const secondRoot = timeline.locator('[data-message-id="mock-general-alice"]');
+ const historyLength = await page.evaluate(() => window.history.length);
+
+ await firstRoot.hover();
+ await secondRoot.hover();
+ await page.evaluate(() => {
+ const replyButtons = Array.from(
+ document.querySelectorAll(
+ '[data-message-id="mock-general-welcome"] [aria-label="Reply"], [data-message-id="mock-general-alice"] [aria-label="Reply"]',
+ ),
+ );
+ if (replyButtons.length !== 2) {
+ throw new Error("Expected both thread reply buttons");
+ }
+ replyButtons[0].click();
+ replyButtons[1].click();
+ });
+
+ const threadPanel = page.getByTestId("message-thread-panel");
+ await expect(threadPanel).toBeVisible();
+ await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
+ "Hey team",
+ );
+ await expect(page).toHaveURL(/thread=mock-general-alice/);
+ await expect
+ .poll(() => page.evaluate(() => window.history.length))
+ .toBe(historyLength + 1);
+});
+
+test("rapid same-thread toggle leaves no panel or stale history entry", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const root = page
+ .getByTestId("message-timeline")
+ .locator('[data-message-id="mock-general-alice"]');
+ const historyLength = await page.evaluate(() => window.history.length);
+ await root.hover();
+ const reply = root.getByRole("button", { name: "Reply" });
+ await reply.dispatchEvent("click");
+ await reply.dispatchEvent("click");
+
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("message-thread-panel")).toHaveCount(0);
+ await expect(page).not.toHaveURL(/thread=/);
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
+test("channel navigation supersedes a pending thread without stale history", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const root = page
+ .getByTestId("message-timeline")
+ .locator('[data-message-id="mock-general-alice"]');
+ const historyLength = await page.evaluate(() => window.history.length);
+ await root.hover();
+ await page.evaluate(() => {
+ document
+ .querySelector(
+ '[data-message-id="mock-general-alice"] [aria-label="Reply"]',
+ )
+ ?.click();
+ document
+ .querySelector('[data-testid="channel-random"]')
+ ?.click();
+ });
+
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("message-thread-panel")).toHaveCount(0);
+ await expect(page).not.toHaveURL(/thread=/);
+ expect(await page.evaluate(() => window.history.length)).toBe(
+ historyLength + 1,
+ );
+});
+
+test("back navigation supersedes a pending thread without stale history", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const root = page
+ .getByTestId("message-timeline")
+ .locator('[data-message-id="mock-general-alice"]');
+ const historyLength = await page.evaluate(() => window.history.length);
+ await root.hover();
+ await page.evaluate(() => {
+ document
+ .querySelector(
+ '[data-message-id="mock-general-alice"] [aria-label="Reply"]',
+ )
+ ?.click();
+ document
+ .querySelector('[data-testid="global-back"]')
+ ?.click();
+ });
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByTestId("home-inbox")).toBeVisible();
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("message-thread-panel")).toHaveCount(0);
+ await expect(page).not.toHaveURL(/thread=/);
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
+test("forward navigation supersedes a pending thread without stale history", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await page.getByTestId("channel-random").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await page.getByTestId("global-back").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const root = page
+ .getByTestId("message-timeline")
+ .locator('[data-message-id="mock-general-alice"]');
+ const historyLength = await page.evaluate(() => window.history.length);
+ await root.hover();
+ await page.evaluate(() => {
+ document
+ .querySelector(
+ '[data-message-id="mock-general-alice"] [aria-label="Reply"]',
+ )
+ ?.click();
+ document
+ .querySelector('[data-testid="global-forward"]')
+ ?.click();
+ });
+
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await expect(page).toHaveURL(/#\/channels\/[^?]+$/);
+ await page.waitForTimeout(100);
+ await expect(page.getByTestId("message-thread-panel")).toHaveCount(0);
+ await expect(page).not.toHaveURL(/thread=/);
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
+});
+
test("thread composer is focused after clicking the reply icon", async ({
page,
}) => {
diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts
index 61a2753baa..aced1c1097 100644
--- a/desktop/tests/e2e/virtualization.spec.ts
+++ b/desktop/tests/e2e/virtualization.spec.ts
@@ -236,6 +236,19 @@ test.describe("list virtualization", () => {
);
}, expectedId);
+ // Transfer scroll ownership away from initial bottom settling with the same
+ // native input path a reader uses. The deterministic scrollTop assignments
+ // below position each boundary crossing; by themselves they do not emit
+ // wheel/pointer/touch intent and therefore cannot retire bottom settling.
+ const initialBox = await timeline.boundingBox();
+ if (!initialBox) throw new Error("timeline has no bounding box");
+ await page.mouse.move(
+ initialBox.x + initialBox.width / 2,
+ initialBox.y + initialBox.height / 2,
+ );
+ await page.mouse.wheel(0, -1);
+ await page.waitForTimeout(50);
+
// Load fifteen consecutive server pages in one mounted virtualizer. This
// is the production shape that exposed the intermittent end-cache snap:
// variable-height rows and repeated front insertions exercise the full
@@ -563,16 +576,18 @@ test.describe("list virtualization", () => {
});
});
-test("thread-heavy history mounts every loaded row", async ({ page }) => {
+test("thread-heavy history keeps a bounded painted viewport", async ({
+ page,
+}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
- // Seed summaries on 120 loaded roots. Every loaded row should be realized
- // immediately so first-pass scrolling never encounters Virtua's hidden
- // pre-measurement state.
+ // Seed summaries on 120 loaded roots. The bounded retention window should
+ // still paint every mounted row, without realizing the complete 50-root
+ // relay page before the reader moves through it.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -603,9 +618,15 @@ test("thread-heavy history mounts every loaded row", async ({ page }) => {
await page.waitForTimeout(300);
const loadedRows = timeline.locator("[data-message-id]");
- // The mock channel's current loaded window contains 50 roots; all of them
- // must already exist and be painted before the first scroll gesture.
- await expect(loadedRows).toHaveCount(50);
+ const loadedRowCount = await loadedRows.count();
+ // The current relay window still carries 50 roots, while only the viewport
+ // plus the narrow keep-mounted bands should exist as live row DOM.
+ expect(loadedRowCount).toBeGreaterThan(6);
+ expect(loadedRowCount).toBeLessThan(50);
+ const completeSnapshotCount = await page
+ .locator("[data-live-message-count]")
+ .evaluate((element) => Number(element.dataset.liveMessageCount ?? "0"));
+ expect(completeSnapshotCount).toBeGreaterThan(loadedRowCount);
expect(
await loadedRows.evaluateAll((rows) =>
rows.every((row) => getComputedStyle(row).visibility === "visible"),
@@ -810,10 +831,8 @@ test("live tail arrivals stay buffered while reading and release on jump", async
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
- await timeline.evaluate((element) => {
- element.scrollTop = Math.max(500, element.scrollHeight / 2);
- element.dispatchEvent(new Event("scroll", { bubbles: true }));
- });
+ await timeline.hover();
+ await page.mouse.wheel(0, -800);
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
const frozenHeight = await timeline.evaluate(
(element) => element.scrollHeight,
diff --git a/desktop/tests/e2e/warm-switch-markdown.perf.ts b/desktop/tests/e2e/warm-switch-markdown.perf.ts
index 291a052f14..fb7d1f655b 100644
--- a/desktop/tests/e2e/warm-switch-markdown.perf.ts
+++ b/desktop/tests/e2e/warm-switch-markdown.perf.ts
@@ -9,23 +9,21 @@ import { installMockBridge } from "../helpers/bridge";
* already in the React Query cache (the everyday alt-tab-between-channels
* motion). The timeline subtree is keyed by channel id — required so TanStack
* Router's scroll restoration never writes a stale scrollTop into a reused
- * scroll node — so every switch unmounts and remounts all rows, and each
- * `MessageRow` re-runs the synchronous react-markdown parse pipeline from
- * scratch. This spec is the instrument for that cost.
+ * scroll node. Warm entry therefore mounts a bounded route-correct slice first,
+ * then releases the complete cached snapshot in one transition. This spec gates
+ * that cost and proves the release cannot strand a partial timeline.
*
* TWO SCENARIOS, one per axis of the cost:
- * plain-text — `deep-history` (600 seeded one-line rows; the initial
- * channel window mounts ~50 of them, verified by parse
- * count): isolates the per-row remount floor.
+ * plain-text — `deep-history` (600 seeded one-line rows; the cached
+ * channel window retains 50): isolates the remount floor.
* markdown — `random` + 60 injected markdown-heavy rows (code fences,
- * tables, lists, mentions, links): isolates the parse cost the
- * markdown cache is meant to remove.
+ * tables, lists, mentions, links): exercises expensive visible
+ * DOM creation and style recalculation.
*
* WHAT A "SWITCH" MEASURES: performance.now() immediately before an in-page
- * .click() on the sidebar link, until (chat title flipped) AND (>= 1 message
- * row committed) AND (no [data-render-pending="true"], i.e. the deferred
- * timeline snapshot caught up to the live one) AND a double-rAF so a frame
- * actually painted. The click and the polling both run in-page so CDP
+ * .click() on the sidebar link, until (chat title flipped) AND the target
+ * channel's expected visible viewport row is painted at the restored bottom
+ * offset with the complete cached snapshot admitted. The click and the
* round-trip latency never pollutes the numbers. Longtask totals are captured
* per switch as the "UI froze" axis (see cold-switch-longtask.perf.ts for the
* rationale).
@@ -37,8 +35,8 @@ import { installMockBridge } from "../helpers/bridge";
* the same machine are.
*
* Run it (from desktop/):
- * pnpm build
- * npx playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts
+ * pnpm build:e2e
+ * pnpm exec playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts
*
* NOTE: the perf web server reuses an existing server on :4173 — if one is
* already running, kill it or make sure `dist/` is freshly built, otherwise
@@ -48,6 +46,10 @@ import { installMockBridge } from "../helpers/bridge";
const MEASURED_SWITCHES = 8;
const THROTTLE_RATE = 4;
const MARKDOWN_MESSAGE_COUNT = 60;
+const PLAIN_MAX_LONGTASK_MS = 300;
+const MARKDOWN_MAX_LONGTASK_MS = 300;
+const PLAIN_MAX_CORRECT_PAINT_MS = 550;
+const MARKDOWN_MAX_CORRECT_PAINT_MS = 325;
/** One representative agent-style message: fence, table, list, mention,
* emphasis, inline code, and a link — the mix real Buzz channels carry. */
@@ -80,6 +82,19 @@ type SwitchSample = {
longtaskTotal: number;
longtaskMax: number;
longtaskCount: number;
+ liveMessageCount: number;
+ renderedMessageCount: number;
+ visibleMessageIds: string[];
+ distanceFromBottom: number;
+ rowRenderCount: number;
+ mountedRowCount: number;
+};
+
+type ScenarioResult = {
+ samples: SwitchSample[];
+ cachedMessageCount: number;
+ expectedVisibleMessageIds: string[];
+ windowFetches: number;
};
function median(values: number[]): number {
@@ -108,15 +123,19 @@ async function waitForMockLiveSubscription(
}
/** Click the sidebar link and poll — all in-page — until the target channel's
- * rows are committed, the deferred snapshot has caught up, and a frame
- * painted. Returns wall-clock ms plus the longtasks observed in the window. */
+ * correct visible viewport is committed and a frame paints. Returns wall-clock
+ * ms plus the longtasks observed in the window. */
async function measureSwitch(
page: import("@playwright/test").Page,
input: { targetTestId: string; targetTitle: string; rowSelector: string },
): Promise {
return page.evaluate(async (args) => {
- const store = window as unknown as { __LONGTASKS__: number[] };
+ const store = window as unknown as {
+ __LONGTASKS__: number[];
+ __TIMELINE_ROW_RENDER_COUNT__?: number;
+ };
store.__LONGTASKS__ = [];
+ store.__TIMELINE_ROW_RENDER_COUNT__ = 0;
const link = document.querySelector(
`[data-testid="${args.targetTestId}"]`,
);
@@ -131,10 +150,27 @@ async function measureSwitch(
const title = document.querySelector(
'[data-testid="chat-title"]',
)?.textContent;
+ const timeline = document.querySelector(
+ '[data-testid="message-timeline"]',
+ );
+ const counts = document.querySelector(
+ "[data-rendered-message-count]",
+ );
+ const snapshotsMatch =
+ Number(counts?.dataset.liveMessageCount ?? "0") > 0 &&
+ counts?.dataset.renderedMessageCount ===
+ counts?.dataset.liveMessageCount;
+ const atBottom = timeline
+ ? timeline.scrollHeight -
+ timeline.clientHeight -
+ timeline.scrollTop <=
+ 2
+ : false;
const ready =
title === args.targetTitle &&
document.querySelector(args.rowSelector) !== null &&
- document.querySelector('[data-render-pending="true"]') === null;
+ snapshotsMatch &&
+ atBottom;
if (ready) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
return;
@@ -150,11 +186,46 @@ async function measureSwitch(
const elapsed = performance.now() - start;
const tasks = store.__LONGTASKS__ ?? [];
+ const snapshot = document.querySelector(
+ "[data-rendered-message-count]",
+ );
+ const timeline = document.querySelector(
+ '[data-testid="message-timeline"]',
+ );
+ const timelineRect = timeline?.getBoundingClientRect();
+ const visibleMessageIds =
+ timeline && timelineRect
+ ? Array.from(
+ timeline.querySelectorAll("[data-message-id]"),
+ )
+ .filter((row) => {
+ const rect = row.getBoundingClientRect();
+ return (
+ rect.bottom > timelineRect.top && rect.top < timelineRect.bottom
+ );
+ })
+ .map((row) => row.dataset.messageId ?? "")
+ .filter(Boolean)
+ : [];
+ const distanceFromBottom = timeline
+ ? timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop
+ : Number.POSITIVE_INFINITY;
+ const liveMessageCount = Number(snapshot?.dataset.liveMessageCount ?? "0");
+ const renderedMessageCount = Number(
+ snapshot?.dataset.renderedMessageCount ?? "0",
+ );
return {
ms: elapsed,
longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0),
longtaskMax: tasks.length ? Math.max(...tasks) : 0,
longtaskCount: tasks.length,
+ liveMessageCount,
+ renderedMessageCount,
+ visibleMessageIds,
+ distanceFromBottom,
+ rowRenderCount: store.__TIMELINE_ROW_RENDER_COUNT__ ?? 0,
+ mountedRowCount:
+ timeline?.querySelectorAll("[data-message-id]").length ?? 0,
};
}, input);
}
@@ -167,17 +238,27 @@ async function runScenario(
targetTitle: string;
rowSelector: string;
},
-): Promise {
+): Promise {
const back = {
targetTestId: "channel-general",
targetTitle: "general",
rowSelector: "[data-message-id]",
};
- // Untimed warmup round-trip: caches both channels' queries.
- await measureSwitch(page, input);
+ // Untimed warmup round-trip: caches both channels' queries. Its settled
+ // message count is the complete cached window every measured re-entry must
+ // release after the bounded first paint.
+ const warmup = await measureSwitch(page, input);
+ const cachedMessageCount = warmup.liveMessageCount;
+ const expectedVisibleMessageIds = warmup.visibleMessageIds;
await measureSwitch(page, back);
+ await page.evaluate(() => {
+ (
+ window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }
+ ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0;
+ });
+
const samples: SwitchSample[] = [];
for (let run = 0; run < MEASURED_SWITCHES; run += 1) {
samples.push(await measureSwitch(page, input));
@@ -195,6 +276,11 @@ async function runScenario(
console.log(
`per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`,
);
+ console.log(
+ `row renders / mounted: [${samples
+ .map((sample) => `${sample.rowRenderCount}/${sample.mountedRowCount}`)
+ .join(", ")}]`,
+ );
console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`);
console.log(
`MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`,
@@ -203,7 +289,53 @@ async function runScenario(
`worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`,
);
/* eslint-enable no-console */
- return samples;
+ const windowFetches = await page.evaluate(
+ () =>
+ (window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number })
+ .__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0,
+ );
+ return {
+ samples,
+ cachedMessageCount,
+ expectedVisibleMessageIds,
+ windowFetches,
+ };
+}
+
+async function fetchOneOlderWindow(
+ page: import("@playwright/test").Page,
+): Promise {
+ return page.evaluate(async () => {
+ const timeline = document.querySelector(
+ '[data-testid="message-timeline"]',
+ );
+ if (!timeline) throw new Error("missing message timeline");
+ const probe = window as unknown as {
+ __CHANNEL_WINDOW_FETCH_COUNT__?: number;
+ };
+ probe.__CHANNEL_WINDOW_FETCH_COUNT__ = 0;
+
+ for (let step = 0; step < 400; step += 1) {
+ timeline.scrollBy(0, -300);
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => window.setTimeout(resolve, 25)),
+ );
+ if ((probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) > 0) break;
+ }
+
+ const deadline = performance.now() + 5_000;
+ while (document.querySelector('[data-render-pending="true"]') !== null) {
+ if (performance.now() > deadline) {
+ throw new Error("older window did not finish rendering");
+ }
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+ }
+ // Give a faulty re-armed observer time to issue a duplicate request.
+ await new Promise((resolve) => window.setTimeout(resolve, 250));
+ return probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0;
+ });
}
test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async ({
@@ -285,9 +417,55 @@ test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
- // Instrument, not a gate: assert the harness measured real work.
- expect(plain.length).toBe(MEASURED_SWITCHES);
- expect(markdown.length).toBe(MEASURED_SWITCHES);
- expect(plain.every((sample) => sample.ms > 0)).toBe(true);
- expect(markdown.every((sample) => sample.ms > 0)).toBe(true);
+ expect(plain.samples).toHaveLength(MEASURED_SWITCHES);
+ expect(markdown.samples).toHaveLength(MEASURED_SWITCHES);
+ expect(plain.cachedMessageCount).toBeGreaterThan(6);
+ expect(markdown.cachedMessageCount).toBeGreaterThan(6);
+ expect(plain.expectedVisibleMessageIds.length).toBeGreaterThan(0);
+ expect(markdown.expectedVisibleMessageIds.length).toBeGreaterThan(0);
+ expect(
+ plain.samples.every(
+ (sample) =>
+ sample.liveMessageCount === plain.cachedMessageCount &&
+ sample.visibleMessageIds.some((id) =>
+ plain.expectedVisibleMessageIds.includes(id),
+ ) &&
+ sample.distanceFromBottom <= 2,
+ ),
+ ).toBe(true);
+ expect(
+ markdown.samples.every(
+ (sample) =>
+ sample.liveMessageCount === markdown.cachedMessageCount &&
+ sample.visibleMessageIds.some((id) =>
+ markdown.expectedVisibleMessageIds.includes(id),
+ ) &&
+ sample.distanceFromBottom <= 2,
+ ),
+ ).toBe(true);
+
+ expect(plain.windowFetches).toBe(0);
+ expect(markdown.windowFetches).toBe(0);
+ expect(
+ Math.max(...plain.samples.map((sample) => sample.longtaskMax)),
+ ).toBeLessThanOrEqual(PLAIN_MAX_LONGTASK_MS);
+ expect(
+ Math.max(...markdown.samples.map((sample) => sample.longtaskMax)),
+ ).toBeLessThanOrEqual(MARKDOWN_MAX_LONGTASK_MS);
+ expect(
+ Math.max(...plain.samples.map((sample) => sample.ms)),
+ ).toBeLessThanOrEqual(PLAIN_MAX_CORRECT_PAINT_MS);
+ expect(
+ Math.max(...markdown.samples.map((sample) => sample.ms)),
+ ).toBeLessThanOrEqual(MARKDOWN_MAX_CORRECT_PAINT_MS);
+
+ // A genuine older-history reach remains network-backed and bounded to one
+ // channel-window request. The warm-navigation assertions above prove the
+ // same counter stays at zero when no continuation is needed.
+ await measureSwitch(page, {
+ targetTestId: "channel-deep-history",
+ targetTitle: "deep-history",
+ rowSelector: '[data-message-id^="mock-deep-history-"]',
+ });
+ expect(await fetchOneOlderWindow(page)).toBe(1);
});