From a85fa30e0a42218f79ca596069fa14386a3bd33f Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 14:33:39 -0600 Subject: [PATCH 01/13] perf(desktop): paint warm channels from cache Use authoritative channel-window provenance to distinguish warm empty channels from cold live-seeded caches. Mount each channel timeline with its route-matched cached snapshot and revalidate the retained scrollback extent atomically so background catch-up cannot erase the reader's anchor. Co-authored-by: Carl Signed-off-by: Wes --- .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/channels/ui/ChannelScreen.tsx | 1 + desktop/src/features/messages/hooks.ts | 97 ++++++++++++++++++- .../lib/projectChannelWindow.test.mjs | 60 +++++++++++- .../lib/timelineLoadingState.test.mjs | 27 ++++++ .../messages/lib/timelineLoadingState.ts | 8 ++ .../features/messages/ui/MessageTimeline.tsx | 30 ++---- .../ui/timelineSnapshotProjection.test.mjs | 23 ++++- 8 files changed, 216 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e..cee4593381 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} 0, dataLength: messagesQuery.data?.length ?? null, }, hasSettledThisChannel, diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf..4eeae402a8 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, @@ -245,21 +247,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,11 +349,20 @@ 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, ); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 14ec110add..8890e610a8 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { reconcileFetchedChannelWindow } from "../hooks.ts"; +import { + reconcileFetchedChannelWindow, + reconcileFetchedChannelWindowPages, +} from "../hooks.ts"; import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; import { appendOlderChannelWindow, @@ -287,6 +290,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..097aef80ff 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(), 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). From 084dfb934c14539d8006c3314f37b3d4a0561b80 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 15:01:40 -0600 Subject: [PATCH 02/13] Keep channel virtualizer API registered Channel-keyed timeline mounts register the child virtualizer API before the parent layout effect runs. Do not immediately erase that registration, or deep-link navigation can never initialize its scroll owner. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/features/messages/ui/MessageTimeline.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 097aef80ff..1ff205d7aa 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -265,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 = From 6b1058e754c402c85e72bf2cd3a9635360e2f0d6 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 08:40:07 -0600 Subject: [PATCH 03/13] perf(desktop): retain channel page provenance with warm rows Keep the authoritative channel window alive for the same hour as its projected message cache so revisits cannot lose loader provenance first. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/features/messages/hooks.ts | 7 ++++++- .../features/messages/lib/projectChannelWindow.test.mjs | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4eeae402a8..03e5378d8f 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -228,6 +228,8 @@ export function resolveThreadReplyTarget( }; } +export const CHANNEL_TIMELINE_GC_TIME_MS = 60 * 60 * 1_000; + export function useChannelWindowQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelWindowKey(channel?.id ?? "none"); @@ -238,6 +240,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, }); } @@ -368,7 +373,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { ); }, staleTime: 5 * 60 * 1_000, - gcTime: 60 * 60 * 1_000, + gcTime: CHANNEL_TIMELINE_GC_TIME_MS, }); } diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 8890e610a8..97b00f4518 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { + CHANNEL_TIMELINE_GC_TIME_MS, reconcileFetchedChannelWindow, reconcileFetchedChannelWindowPages, } from "../hooks.ts"; @@ -54,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 } }, From a12e35bfcc3fd70695dc44ebdd2d1cb659036798 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 12:03:09 -0600 Subject: [PATCH 04/13] perf(desktop): bound warm timeline work Skip redundant fresh-window catch-up on warm navigation, memoize stable rows, and narrow the retained DOM to the active viewport and tail. Strengthen the warm-switch and scroll regression gates, including buffered live-tail recovery. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/features/messages/hooks.ts | 39 +-- .../features/messages/ui/MessageTimeline.tsx | 4 +- .../messages/ui/TimelineMessageRow.tsx | 11 +- .../features/messages/ui/timelineRetention.ts | 10 +- .../messages/ui/useTimelineRetention.test.mjs | 18 +- .../messages/ui/useTimelineRetention.ts | 2 +- desktop/src/testing/e2eBridge.ts | 8 +- desktop/tests/e2e/virtualization.spec.ts | 41 +++- .../tests/e2e/warm-switch-markdown.perf.ts | 232 ++++++++++++++++-- 9 files changed, 293 insertions(+), 72 deletions(-) diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 03e5378d8f..c30f98a3c2 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -229,6 +229,7 @@ export function resolveThreadReplyTarget( } export const CHANNEL_TIMELINE_GC_TIME_MS = 60 * 60 * 1_000; +export const CHANNEL_TIMELINE_STALE_TIME_MS = 5 * 60 * 1_000; export function useChannelWindowQuery(channel: Channel | null) { const queryClient = useQueryClient(); @@ -372,7 +373,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { signal, ); }, - staleTime: 5 * 60 * 1_000, + staleTime: CHANNEL_TIMELINE_STALE_TIME_MS, gcTime: CHANNEL_TIMELINE_GC_TIME_MS, }); } @@ -498,19 +499,27 @@ export function useChannelSubscription(channel: Channel | null) { } cleanup = dispose; - // The live subscription starts at "now", so it cannot close the gap - // between the last page snapshot and subscription establishment. Always - // refresh after the subscription is active; freshness alone is not a - // proof that no relay events landed in that interval. - void refreshNewestWindow().catch((error) => { - if (!isDisposed) { - console.error( - "Failed to refresh channel window after subscribing", - channelId, - error, - ); - } - }); + // A fresh cached window already covers ordinary warm navigation. Only + // close the page-to-live gap when the snapshot is absent or old; + // reconnects remain an explicit unconditional catch-up above. + const queryState = queryClient.getQueryState( + channelMessagesKey(channelId), + ); + const snapshotIsFresh = + queryState?.data !== undefined && + Date.now() - queryState.dataUpdatedAt < + CHANNEL_TIMELINE_STALE_TIME_MS; + if (!snapshotIsFresh) { + void refreshNewestWindow().catch((error) => { + if (!isDisposed) { + console.error( + "Failed to refresh channel window after subscribing", + channelId, + error, + ); + } + }); + } }) .catch((error) => { console.error("Failed to subscribe to channel", channelId, error); @@ -523,7 +532,7 @@ export function useChannelSubscription(channel: Channel | null) { void cleanup(); } }; - }, [channelId, channelType]); + }, [channelId, channelType, queryClient]); } export function useSendMessageMutation( diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1ff205d7aa..7e730d4cc9 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -739,7 +739,9 @@ const MessageTimelineBase = React.forwardRef< {useTimelineVirtualizer && timelineList ? (
{timelineList}
@@ -844,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/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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bc785ac252..bf7516e03f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5139,10 +5139,6 @@ async function handleGetChannelWindow( return relayQuery(config, [filter]); }; - if (!args.cursor) { - return execute(); - } - const probe = window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number; __CHANNEL_WINDOW_INFLIGHT__?: number; @@ -5151,6 +5147,10 @@ async function handleGetChannelWindow( probe.__CHANNEL_WINDOW_FETCH_COUNT__ = (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; + if (!args.cursor) { + return execute(); + } + const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0; if (delayMs <= 0) { return execute(); 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); }); From 52f87b051575d8c039a22172559f8083b64c8dcd Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 16:20:08 -0600 Subject: [PATCH 05/13] perf(desktop): paint thread feedback immediately Commit the existing thread skeleton before the deferred thread-panel mount, even when the root is already cached. Keep the expensive panel work transitional and cover the one-frame loading state with a mutation-observer regression test. Co-authored-by: Carl Signed-off-by: Wes --- .../src/features/channels/ui/ChannelPane.tsx | 28 ++++++------- .../features/channels/ui/ChannelScreen.tsx | 14 ++++++- .../channels/useChannelPaneHandlers.ts | 5 +++ desktop/tests/e2e/messaging.spec.ts | 41 +++++++++++++++++++ 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index cee4593381..7f4632b13e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -782,6 +782,20 @@ export const ChannelPane = React.memo(function ChannelPane({ useSplitAuxiliaryPane={useSplitAuxiliaryPane} transparentChrome={hasSplitAuxiliaryPane} /> + ) : shouldShowThreadSkeleton ? ( + (() => { + if (isHuddleTranscript) { + return wrapThreadPanel(); + } + const panel = ( + + ); + return wrapThreadPanel(panel); + })() ) : threadHeadMessage ? ( (() => { const panel = ( @@ -854,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 4bb358b3ab..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, @@ -683,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..5309b81d34 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; @@ -199,6 +201,7 @@ export function useChannelPaneHandlers({ const handleOpenThread = React.useCallback( (message: { id: string }) => { if (openThreadHeadIdRef.current === message.id) { + onPendingOpenThreadHeadIdChange(null); deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); setOpenThreadHeadId(null); @@ -210,6 +213,7 @@ export function useChannelPaneHandlers({ return; } + onPendingOpenThreadHeadIdChange(message.id); deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(message.id); setOpenThreadHeadId(message.id); @@ -222,6 +226,7 @@ export function useChannelPaneHandlers({ [ deferPanelState, onOptimisticOpenThreadHeadIdChange, + onPendingOpenThreadHeadIdChange, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index f93ce0450b..9e902d7ce0 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2781,6 +2781,47 @@ test("composer is focused after switching to a different channel", async ({ await expect(input).toBeFocused(); }); +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"]')) { + observed.loading = true; + observer.disconnect(); + } + }); + 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("thread composer is focused after clicking the reply icon", async ({ page, }) => { From f4556496d4825b5b6d41959317d04cb403738bdc Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 16:25:03 -0600 Subject: [PATCH 06/13] perf(desktop): paint channel selection before navigation Commit the requested sidebar selection first, then start cached channel navigation after the browser has painted. Cancel superseded navigation work and cover the paint ordering with an end-to-end regression test. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 46 ++++++++++++++++++++++++++-- desktop/tests/e2e/messaging.spec.ts | 47 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b72..2ab9043c68 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -109,7 +109,7 @@ export function AppShell() { handleHuddleStartPendingChange, handleHuddleStarted, handleHuddleVisibilityChange, - handleSidebarChannelSelect, + handleSidebarChannelSelect: selectSidebarChannel, huddleBackingChannelIds, revealedHuddleChannelIds, isHuddleCompanionOpen, @@ -153,6 +153,48 @@ export function AppShell() { () => deriveShellRoute(location.pathname), [location.pathname], ); + const [pendingSidebarChannelId, setPendingSidebarChannelId] = React.useState< + string | null + >(null); + const pendingSidebarNavigationFrameRef = React.useRef(null); + const pendingSidebarNavigationTimerRef = React.useRef(null); + React.useEffect(() => { + return () => { + if (pendingSidebarNavigationFrameRef.current !== null) { + window.cancelAnimationFrame(pendingSidebarNavigationFrameRef.current); + } + if (pendingSidebarNavigationTimerRef.current !== null) { + window.clearTimeout(pendingSidebarNavigationTimerRef.current); + } + }; + }, []); + React.useEffect(() => { + if (pendingSidebarChannelId === selectedChannelId) { + setPendingSidebarChannelId(null); + } + }, [pendingSidebarChannelId, selectedChannelId]); + const handleSidebarChannelSelect = React.useCallback( + (channelId: string) => { + setPendingSidebarChannelId(channelId); + if (pendingSidebarNavigationFrameRef.current !== null) { + window.cancelAnimationFrame(pendingSidebarNavigationFrameRef.current); + } + if (pendingSidebarNavigationTimerRef.current !== null) { + window.clearTimeout(pendingSidebarNavigationTimerRef.current); + } + pendingSidebarNavigationFrameRef.current = window.requestAnimationFrame( + () => { + pendingSidebarNavigationFrameRef.current = null; + pendingSidebarNavigationTimerRef.current = window.setTimeout(() => { + pendingSidebarNavigationTimerRef.current = null; + selectSidebarChannel(channelId); + }, 0); + }, + ); + }, + [selectSidebarChannel], + ); + const sidebarSelectedChannelId = pendingSidebarChannelId ?? selectedChannelId; const { removeCommunity: handleRemoveCommunity, switchCommunity: handleSwitchCommunity, @@ -880,7 +922,7 @@ export function AppShell() { ] ?? undefined) : undefined } - selectedChannelId={selectedChannelId} + selectedChannelId={sidebarSelectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} previewActivityChannelIds={unreadThreadChannelIds} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 9e902d7ce0..557ad78801 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2781,6 +2781,53 @@ test("composer is focused after switching to a different channel", async ({ await expect(input).toBeFocused(); }); +test("sidebar selection paints before cached channel work starts", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.evaluate(() => { + const observed = { selectedBeforeRoute: false }; + Object.assign(window, { __BUZZ_SIDEBAR_SELECTION_OBSERVED__: observed }); + const observer = new MutationObserver(() => { + const random = document.querySelector('[data-testid="channel-random"]'); + const title = document.querySelector('[data-testid="chat-title"]'); + if ( + random?.getAttribute("data-active") === "true" && + title?.textContent === "general" + ) { + observed.selectedBeforeRoute = true; + observer.disconnect(); + } + }); + 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; + }; + } + ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__?.selectedBeforeRoute ?? false, + ), + ) + .toBe(true); + await expect(page.getByTestId("chat-title")).toHaveText("random"); +}); + test("thread open paints immediate feedback before deferred panel work", async ({ page, }) => { From 79601009fa1991afb63322db6ac73e660b23beb7 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 17:05:09 -0600 Subject: [PATCH 07/13] fix(desktop): cancel superseded navigation work Track deferred sidebar and thread operations so newer navigation intent, route changes, toggles, and unmounts cancel stale callbacks. Strengthen the E2E coverage to prove painted feedback and latest-intent history semantics. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 71 +++--- .../src/app/navigation/navigationIntent.ts | 3 + .../src/app/navigation/useAppNavigation.ts | 5 + .../app/navigation/useBackForwardControls.ts | 3 + .../useDeferredSidebarNavigation.ts | 88 ++++++++ .../channels/useChannelPaneHandlers.ts | 64 +++++- desktop/tests/e2e/messaging.spec.ts | 208 +++++++++++++++++- 7 files changed, 382 insertions(+), 60 deletions(-) create mode 100644 desktop/src/app/navigation/navigationIntent.ts create mode 100644 desktop/src/app/navigation/useDeferredSidebarNavigation.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 2ab9043c68..3fc65f5041 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"; @@ -153,47 +154,15 @@ export function AppShell() { () => deriveShellRoute(location.pathname), [location.pathname], ); - const [pendingSidebarChannelId, setPendingSidebarChannelId] = React.useState< - string | null - >(null); - const pendingSidebarNavigationFrameRef = React.useRef(null); - const pendingSidebarNavigationTimerRef = React.useRef(null); - React.useEffect(() => { - return () => { - if (pendingSidebarNavigationFrameRef.current !== null) { - window.cancelAnimationFrame(pendingSidebarNavigationFrameRef.current); - } - if (pendingSidebarNavigationTimerRef.current !== null) { - window.clearTimeout(pendingSidebarNavigationTimerRef.current); - } - }; - }, []); - React.useEffect(() => { - if (pendingSidebarChannelId === selectedChannelId) { - setPendingSidebarChannelId(null); - } - }, [pendingSidebarChannelId, selectedChannelId]); - const handleSidebarChannelSelect = React.useCallback( - (channelId: string) => { - setPendingSidebarChannelId(channelId); - if (pendingSidebarNavigationFrameRef.current !== null) { - window.cancelAnimationFrame(pendingSidebarNavigationFrameRef.current); - } - if (pendingSidebarNavigationTimerRef.current !== null) { - window.clearTimeout(pendingSidebarNavigationTimerRef.current); - } - pendingSidebarNavigationFrameRef.current = window.requestAnimationFrame( - () => { - pendingSidebarNavigationFrameRef.current = null; - pendingSidebarNavigationTimerRef.current = window.setTimeout(() => { - pendingSidebarNavigationTimerRef.current = null; - selectSidebarChannel(channelId); - }, 0); - }, - ); - }, - [selectSidebarChannel], - ); + const { + cancel: cancelPendingSidebarNavigation, + pendingChannelId: pendingSidebarChannelId, + selectDeferred: handleSidebarChannelSelect, + } = useDeferredSidebarNavigation({ + pathname: location.pathname, + selectedChannelId, + selectChannel: selectSidebarChannel, + }); const sidebarSelectedChannelId = pendingSidebarChannelId ?? selectedChannelId; const { removeCommunity: handleRemoveCommunity, @@ -889,7 +858,10 @@ export function AppShell() { }); await goChannel(directMessage.id); }} - onSelectAgents={() => void goAgents()} + onSelectAgents={() => { + cancelPendingSidebarNavigation(); + void goAgents(); + }} onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} @@ -897,9 +869,18 @@ export function AppShell() { searchFocusRequest, scopeSearchFocusRequest, ]} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} + onSelectHome={() => { + cancelPendingSidebarNavigation(); + void goHome(); + }} + onSelectProjects={() => { + cancelPendingSidebarNavigation(); + void goProjects(); + }} + onSelectPulse={() => { + cancelPendingSidebarNavigation(); + void goPulse(); + }} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => diff --git a/desktop/src/app/navigation/navigationIntent.ts b/desktop/src/app/navigation/navigationIntent.ts new file mode 100644 index 0000000000..b818ecba7d --- /dev/null +++ b/desktop/src/app/navigation/navigationIntent.ts @@ -0,0 +1,3 @@ +export function dispatchNavigationIntent(): void { + window.dispatchEvent(new Event("buzz:navigation-intent")); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6..7ff48c0aa1 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import { dispatchNavigationIntent } from "@/app/navigation/navigationIntent"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; import type { SearchHit } from "@/shared/api/types"; @@ -38,6 +39,7 @@ export function useAppNavigation() { return false; } + dispatchNavigationIntent(); await navigate({ ...next, replace: behavior.replace, @@ -273,6 +275,7 @@ export function useAppNavigation() { const closeSettings = React.useCallback(() => { 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..28110d2109 --- /dev/null +++ b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { dispatchNavigationIntent } from "@/app/navigation/navigationIntent"; + +type DeferredSidebarNavigationOptions = { + pathname: string; + selectedChannelId: string | null; + selectChannel: (channelId: string) => void; +}; + +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 pathnameRef = React.useRef(pathname); + pathnameRef.current = pathname; + + const cancel = 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; + } + setPendingChannelId(null); + }, []); + + React.useEffect(() => cancel, [cancel]); + React.useEffect(() => { + // A committed route change supersedes any deferred sidebar intent. + void pathname; + cancel(); + }, [cancel, pathname]); + React.useEffect(() => { + if (pendingChannelId === selectedChannelId) setPendingChannelId(null); + }, [pendingChannelId, selectedChannelId]); + + const selectDeferred = React.useCallback( + (channelId: string) => { + 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; + } + selectChannel(channelId); + }, 0); + }); + }); + }, + [cancel, selectChannel], + ); + + return { cancel, pendingChannelId, selectDeferred }; +} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 5309b81d34..39a09f475d 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -109,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); @@ -130,6 +183,7 @@ export function useChannelPaneHandlers({ }, [ deferPanelState, onOptimisticOpenThreadHeadIdChange, + onPendingOpenThreadHeadIdChange, setExpandedThreadReplyIds, setOpenThreadHeadId, setThreadReplyTargetId, @@ -200,7 +254,8 @@ 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); @@ -213,6 +268,7 @@ export function useChannelPaneHandlers({ return; } + intendedOpenThreadHeadIdRef.current = message.id; onPendingOpenThreadHeadIdChange(message.id); deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(message.id); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 557ad78801..bf32cb4dd1 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2793,14 +2793,14 @@ test("sidebar selection paints before cached channel work starts", async ({ Object.assign(window, { __BUZZ_SIDEBAR_SELECTION_OBSERVED__: observed }); const observer = new MutationObserver(() => { const random = document.querySelector('[data-testid="channel-random"]'); - const title = document.querySelector('[data-testid="chat-title"]'); - if ( - random?.getAttribute("data-active") === "true" && - title?.textContent === "general" - ) { - observed.selectedBeforeRoute = true; - observer.disconnect(); - } + if (random?.getAttribute("data-active") !== "true") return; + observer.disconnect(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const title = document.querySelector('[data-testid="chat-title"]'); + observed.selectedBeforeRoute = title?.textContent === "general"; + }); + }); }); observer.observe(document.body, { attributes: true, @@ -2828,6 +2828,25 @@ test("sidebar selection paints before cached channel work starts", async ({ await expect(page.getByTestId("chat-title")).toHaveText("random"); }); +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.getByTestId("channel-random").click(); + await page.getByRole("button", { name: "Inbox" }).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("thread open paints immediate feedback before deferred panel work", async ({ page, }) => { @@ -2845,10 +2864,17 @@ test("thread open paints immediate feedback before deferred panel work", async ( const observed = { loading: false }; Object.assign(window, { __BUZZ_THREAD_OPEN_OBSERVED__: observed }); const observer = new MutationObserver(() => { - if (document.querySelector('[data-testid="message-thread-loading"]')) { - observed.loading = true; - observer.disconnect(); + 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 }); }); @@ -2869,6 +2895,166 @@ test("thread open paints immediate feedback before deferred panel work", async ( 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, }) => { From 4b399d8a9139b17bb93cc814eef127f5342731d0 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 17:56:18 -0600 Subject: [PATCH 08/13] Cancel deferred sidebar navigation on newer intent Subscribe the sidebar deferral to the shared navigation-intent signal so back, forward, deep links, and every other navigation source supersede stale channel work. Keep optimistic selection until the committed route catches up, and cover immediate competing intents plus traversal races. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 25 ++------ .../useDeferredSidebarNavigation.ts | 19 +++++- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 1 + desktop/tests/e2e/messaging.spec.ts | 64 ++++++++++++++++++- 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 3fc65f5041..91f17cf185 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -155,7 +155,6 @@ export function AppShell() { [location.pathname], ); const { - cancel: cancelPendingSidebarNavigation, pendingChannelId: pendingSidebarChannelId, selectDeferred: handleSidebarChannelSelect, } = useDeferredSidebarNavigation({ @@ -858,10 +857,7 @@ export function AppShell() { }); await goChannel(directMessage.id); }} - onSelectAgents={() => { - cancelPendingSidebarNavigation(); - void goAgents(); - }} + onSelectAgents={() => void goAgents()} onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} @@ -869,18 +865,9 @@ export function AppShell() { searchFocusRequest, scopeSearchFocusRequest, ]} - onSelectHome={() => { - cancelPendingSidebarNavigation(); - void goHome(); - }} - onSelectProjects={() => { - cancelPendingSidebarNavigation(); - void goProjects(); - }} - onSelectPulse={() => { - cancelPendingSidebarNavigation(); - void goPulse(); - }} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => @@ -904,7 +891,9 @@ export function AppShell() { : undefined } selectedChannelId={sidebarSelectedChannelId} - selectedView={selectedView} + selectedView={ + pendingSidebarChannelId ? "channel" : selectedView + } unreadChannelIds={unreadChannelIds} previewActivityChannelIds={unreadThreadChannelIds} unreadChannelCounts={unreadChannelCounts} diff --git a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts index 28110d2109..b2800280d4 100644 --- a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts +++ b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts @@ -22,7 +22,7 @@ export function useDeferredSidebarNavigation({ const pathnameRef = React.useRef(pathname); pathnameRef.current = pathname; - const cancel = React.useCallback(() => { + const cancelDeferred = React.useCallback(() => { generationRef.current += 1; if (frameRef.current !== null) { window.cancelAnimationFrame(frameRef.current); @@ -32,10 +32,23 @@ export function useDeferredSidebarNavigation({ window.clearTimeout(timerRef.current); timerRef.current = null; } - setPendingChannelId(null); }, []); + const cancel = React.useCallback(() => { + cancelDeferred(); + setPendingChannelId(null); + }, [cancelDeferred]); + React.useEffect(() => cancel, [cancel]); + React.useEffect(() => { + const handleNavigationIntent = () => cancelDeferred(); + window.addEventListener("buzz:navigation-intent", handleNavigationIntent); + return () => + window.removeEventListener( + "buzz:navigation-intent", + handleNavigationIntent, + ); + }, [cancelDeferred]); React.useEffect(() => { // A committed route change supersedes any deferred sidebar intent. void pathname; @@ -84,5 +97,5 @@ export function useDeferredSidebarNavigation({ [cancel, selectChannel], ); - return { cancel, pendingChannelId, selectDeferred }; + return { pendingChannelId, selectDeferred }; } 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({ { + 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); @@ -2847,6 +2855,58 @@ test("superseded sidebar feedback cannot override newer navigation", async ({ ); }); +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, }) => { From 73101d2d7fbe5d288c54b1c1f1be5b4c4bd646ef Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 10:11:38 -0600 Subject: [PATCH 09/13] fix(desktop): avoid overlapping channel selection paint The sidebar updates active ownership immediately during deferred navigation, but its shared background transition visually retained the old active row while fading in the new one. Exclude background and text colors from channel-row transitions so selection changes atomically, and pin both singular ownership and transition behavior at the pre-route paint boundary. Co-authored-by: Carl Signed-off-by: Wes --- .../features/sidebar/ui/SidebarSection.tsx | 5 +++- desktop/tests/e2e/messaging.spec.ts | 30 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24..5fe1aa3158 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -302,7 +302,10 @@ export function ChannelMenuButton({ const button = ( { - const observed = { selectedBeforeRoute: false }; + const observed = { + selectedBeforeRoute: false, + singularSelection: false, + activeBackgroundTransitions: true, + }; Object.assign(window, { __BUZZ_SIDEBAR_SELECTION_OBSERVED__: observed }); 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(); @@ -2799,6 +2804,19 @@ test("sidebar selection paints before cached channel work starts", async ({ requestAnimationFrame(() => { const title = document.querySelector('[data-testid="chat-title"]'); observed.selectedBeforeRoute = title?.textContent === "general"; + 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"), + ); }); }); }); @@ -2819,12 +2837,18 @@ test("sidebar selection paints before cached channel work starts", async ({ window as Window & { __BUZZ_SIDEBAR_SELECTION_OBSERVED__?: { selectedBeforeRoute: boolean; + singularSelection: boolean; + activeBackgroundTransitions: boolean; }; } - ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__?.selectedBeforeRoute ?? false, + ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__ ?? null, ), ) - .toBe(true); + .toEqual({ + selectedBeforeRoute: true, + singularSelection: true, + activeBackgroundTransitions: false, + }); await expect(page.getByTestId("chat-title")).toHaveText("random"); }); From 7757f6d658b735caa6c392d324fc9031846e355c Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 11:31:00 -0600 Subject: [PATCH 10/13] fix(desktop): replace stale channel during navigation Render the destination channel skeleton as soon as sidebar intent is recorded instead of leaving the previous channel visible while cached content renders. Keep that skeleton through route commit and one paint, clear it safely on superseding or failed navigation, and cover both paint boundaries in E2E. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 12 +++++- .../useDeferredSidebarNavigation.ts | 38 ++++++++++++++---- desktop/src/app/useHuddlePresentation.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 39 ++++++++++++++++++- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 91f17cf185..7f26a8eee6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -87,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"; @@ -911,7 +912,16 @@ export function AppShell() { mainInsetRef={mainInsetRef} terminal={} > - + {pendingSidebarChannelId ? ( +
+ +
+ ) : ( + + )} {!isHuddleRoom ? ( void; + selectChannel: (channelId: string) => Promise | undefined; }; export function useDeferredSidebarNavigation({ @@ -19,6 +19,8 @@ export function useDeferredSidebarNavigation({ 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; @@ -35,27 +37,42 @@ export function useDeferredSidebarNavigation({ }, []); const cancel = React.useCallback(() => { + isCommittingRef.current = false; + ignoreNextNavigationIntentRef.current = false; cancelDeferred(); setPendingChannelId(null); }, [cancelDeferred]); React.useEffect(() => cancel, [cancel]); React.useEffect(() => { - const handleNavigationIntent = () => cancelDeferred(); + const handleNavigationIntent = () => { + if (ignoreNextNavigationIntentRef.current) { + ignoreNextNavigationIntentRef.current = false; + return; + } + cancel(); + }; window.addEventListener("buzz:navigation-intent", handleNavigationIntent); return () => window.removeEventListener( "buzz:navigation-intent", handleNavigationIntent, ); - }, [cancelDeferred]); + }, [cancel]); React.useEffect(() => { - // A committed route change supersedes any deferred sidebar intent. + // 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; - cancel(); + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + cancel(); + }); }, [cancel, pathname]); React.useEffect(() => { - if (pendingChannelId === selectedChannelId) setPendingChannelId(null); + if (!isCommittingRef.current && pendingChannelId === selectedChannelId) { + setPendingChannelId(null); + } }, [pendingChannelId, selectedChannelId]); const selectDeferred = React.useCallback( @@ -89,7 +106,14 @@ export function useDeferredSidebarNavigation({ ) { return; } - selectChannel(channelId); + isCommittingRef.current = true; + ignoreNextNavigationIntentRef.current = true; + const navigationResult = selectChannel(channelId); + if (navigationResult) { + void navigationResult.catch(() => { + if (generationRef.current === generation) cancel(); + }); + } }, 0); }); }); diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index a82916d044..6d9d0dc547 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -319,7 +319,7 @@ export function useHuddlePresentation() { showHuddleInMainApp(channelId); return; } - void goChannel(channelId); + return goChannel(channelId); }, [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], ); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index d5f4d5220d..7fe82648ca 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2789,12 +2789,38 @@ test("sidebar selection paints before cached channel work starts", async ({ 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"]'); @@ -2802,8 +2828,10 @@ test("sidebar selection paints before cached channel work starts", async ({ observer.disconnect(); requestAnimationFrame(() => { requestAnimationFrame(() => { - const title = document.querySelector('[data-testid="chat-title"]'); - observed.selectedBeforeRoute = title?.textContent === "general"; + 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( @@ -2839,6 +2867,9 @@ test("sidebar selection paints before cached channel work starts", async ({ selectedBeforeRoute: boolean; singularSelection: boolean; activeBackgroundTransitions: boolean; + destinationSkeletonBeforeRoute: boolean; + destinationSkeletonAfterRoute: boolean; + destinationContentAfterSkeleton: boolean; }; } ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__ ?? null, @@ -2848,7 +2879,11 @@ test("sidebar selection paints before cached channel work starts", async ({ 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"); }); From c6ea3851868aee2f2eb59a4b1c12718624e7b593 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 11:35:02 -0600 Subject: [PATCH 11/13] fix(desktop): skip deferred navigation for selected channel Avoid replacing an already-selected channel with a navigation skeleton when its sidebar item is clicked again. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/navigation/useDeferredSidebarNavigation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts index 6a7a7a279a..d9e67e186a 100644 --- a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts +++ b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts @@ -77,6 +77,10 @@ export function useDeferredSidebarNavigation({ const selectDeferred = React.useCallback( (channelId: string) => { + if (channelId === selectedChannelId) { + selectChannel(channelId); + return; + } dispatchNavigationIntent(); cancel(); const generation = generationRef.current; @@ -118,7 +122,7 @@ export function useDeferredSidebarNavigation({ }); }); }, - [cancel, selectChannel], + [cancel, selectChannel, selectedChannelId], ); return { pendingChannelId, selectDeferred }; From e17c4df27eabf40ff890300a146b1d0b134a6797 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 12:19:12 -0600 Subject: [PATCH 12/13] fix(desktop): cancel superseded channel reselect Return huddle navigation promises so deferred navigation can clear its skeleton when route commits fail, and cover reselecting the current channel while a new channel is pending. Co-authored-by: Carl Signed-off-by: Wes --- .../useDeferredSidebarNavigation.ts | 9 +++++-- desktop/src/app/useHuddlePresentation.ts | 4 +-- desktop/tests/e2e/messaging.spec.ts | 26 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts index d9e67e186a..e1f5d8faa7 100644 --- a/desktop/src/app/navigation/useDeferredSidebarNavigation.ts +++ b/desktop/src/app/navigation/useDeferredSidebarNavigation.ts @@ -78,7 +78,12 @@ export function useDeferredSidebarNavigation({ const selectDeferred = React.useCallback( (channelId: string) => { if (channelId === selectedChannelId) { - selectChannel(channelId); + if (pendingChannelId !== null) { + dispatchNavigationIntent(); + cancel(); + } + const navigationResult = selectChannel(channelId); + if (navigationResult) void navigationResult.catch(() => undefined); return; } dispatchNavigationIntent(); @@ -122,7 +127,7 @@ export function useDeferredSidebarNavigation({ }); }); }, - [cancel, selectChannel, selectedChannelId], + [cancel, pendingChannelId, selectChannel, selectedChannelId], ); return { pendingChannelId, selectDeferred }; diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index 6d9d0dc547..d94226ca4e 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], ); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 7fe82648ca..581f7b3c7e 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2887,6 +2887,32 @@ test("sidebar selection paints before cached channel work starts", async ({ 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, }) => { From 448587bc2db55125bff355508b60f48ba3fdaad6 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 12:42:57 -0600 Subject: [PATCH 13/13] fix(desktop): propagate huddle sidebar navigation Return the huddle route promise through the sidebar handler so deferred navigation can clear its destination skeleton if the route commit fails. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/useHuddlePresentation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index d94226ca4e..475f8f9323 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -316,8 +316,7 @@ export function useHuddlePresentation() { isHuddleDrawerOpen && channelId === activeHuddleChannelIdRef.current ) { - showHuddleInMainApp(channelId); - return; + return showHuddleInMainApp(channelId); } return goChannel(channelId); },