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); });