diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f67dfa72763..3038a943353 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -112,6 +112,7 @@ export default defineConfig({ "**/timeline-no-shift.spec.ts", "**/thread-summary-stability.spec.ts", "**/channel-revisit-no-skeleton.spec.ts", + "**/sidebar-hover-prefetch.spec.ts", "**/human-edit-agent-content.spec.ts", "**/empty-edit-delete.spec.ts", "**/reaction-order.spec.ts", diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 5cefd1ca8d8..1bf4456f2a2 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,6 +34,7 @@ import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; +import { resetChannelWindowPrefetches } from "@/features/messages/lib/channelWindowPrefetches"; import { resetSettledTimelineChannels } from "@/features/messages/lib/settledTimelineChannels"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; @@ -58,6 +59,7 @@ async function resetCommunityState({ resetAvatarState: boolean; }): Promise { relayClient.disconnect(); + resetChannelWindowPrefetches(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index d28f2926081..b9406ad0a6f 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -29,6 +29,10 @@ import { channelHeadHydration, consumeHydratedChannel, } from "@/features/messages/lib/channelHeadCache"; +import { + markChannelPrefetchSettled, + markChannelPrefetchStarted, +} from "@/features/messages/lib/channelWindowPrefetches"; import { storeChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; import { mergeMessages, @@ -284,13 +288,26 @@ export function reconcileFetchedChannelWindow( return reconcileChannelWindowMessages(next, previousMessages); } -export function useChannelMessagesQuery(channel: Channel | null) { - const queryClient = useQueryClient(); +export const CHANNEL_MESSAGES_STALE_TIME_MS = 5 * 60 * 1_000; +// Window-guarded like react-query's own server default (Infinity): an +// explicit finite gcTime schedules a real, non-unref'd timeout per cache +// entry, which keeps node test processes alive for the full hour. +export const CHANNEL_MESSAGES_GC_TIME_MS = + typeof window === "undefined" ? Number.POSITIVE_INFINITY : 60 * 60 * 1_000; + +/** + * Shared query options for a channel's message window — the single source + * for `useChannelMessagesQuery` and the sidebar hover prefetch, so a + * prefetched entry is a byte-identical cache hit for the mounted query. + */ +export function channelMessagesQueryOptions( + queryClient: QueryClient, + channel: Channel | null, +) { const queryKey = channelMessagesKey(channel?.id ?? "none"); - return useQuery({ - enabled: channel !== null && channel.channelType !== "forum", + return { queryKey, - queryFn: async ({ signal }) => { + queryFn: async ({ signal }: { signal: AbortSignal }) => { if (!channel) throw new Error("No channel selected."); // Persisted heads seed asynchronously; wait for that seed so a channel // opened during boot takes the hydrated path instead of racing it with @@ -310,8 +327,34 @@ export function useChannelMessagesQuery(channel: Channel | null) { signal, ); }, - staleTime: 5 * 60 * 1_000, - gcTime: 60 * 60 * 1_000, + staleTime: CHANNEL_MESSAGES_STALE_TIME_MS, + gcTime: CHANNEL_MESSAGES_GC_TIME_MS, + }; +} + +/** + * Warms a channel's message window ahead of navigation (sidebar hover + * intent). Respects staleTime — a fresh window is a no-op — and dedupes with + * any in-flight fetch. Forums own their data elsewhere; huddle/forum-less + * gating matches useChannelMessagesQuery's enabled condition. + */ +export function prefetchChannelMessages( + queryClient: QueryClient, + channel: Channel, +): void { + if (channel.channelType === "forum") return; + markChannelPrefetchStarted(channel.id); + void queryClient + .prefetchQuery(channelMessagesQueryOptions(queryClient, channel)) + .finally(() => markChannelPrefetchSettled(channel.id)); +} + +export function useChannelMessagesQuery(channel: Channel | null) { + const queryClient = useQueryClient(); + + return useQuery({ + enabled: channel !== null && channel.channelType !== "forum", + ...channelMessagesQueryOptions(queryClient, channel), }); } diff --git a/desktop/src/features/messages/lib/channelWindowPrefetches.ts b/desktop/src/features/messages/lib/channelWindowPrefetches.ts new file mode 100644 index 00000000000..922194dd3c2 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowPrefetches.ts @@ -0,0 +1,28 @@ +const inFlight = new Set(); + +/** + * Tracks channels whose message window is being warmed by a hover prefetch. + * + * A prefetch reaches the relay before the channel's live subscription exists, + * so its snapshot can miss events that land in between; the post-subscribe + * refresh must replace it rather than dedupe onto it. A cold mount fetch is + * parked on the persisted-head gate and has not reached the relay yet, so it + * carries no such gap and still dedupes. Nothing else distinguishes the two + * in-flight fetches from `refreshChannelWindowMessages`. + */ +export function markChannelPrefetchStarted(channelId: string): void { + inFlight.add(channelId); +} + +export function markChannelPrefetchSettled(channelId: string): void { + inFlight.delete(channelId); +} + +export function hasInFlightChannelPrefetch(channelId: string): boolean { + return inFlight.has(channelId); +} + +/** Community-scoped: cleared by `resetCommunityState`. */ +export function resetChannelWindowPrefetches(): void { + inFlight.clear(); +} diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc9912..b4a4001b517 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -16,6 +16,11 @@ import { refreshChannelWindowMessages, } from "./projectChannelWindow.ts"; import { reconcileChannelWindowMessages } from "./channelWindowReconciliation.ts"; +import { + markChannelPrefetchSettled, + markChannelPrefetchStarted, + resetChannelWindowPrefetches, +} from "./channelWindowPrefetches.ts"; function event(id, createdAt) { return { @@ -416,3 +421,108 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("gap refresh refetches after an in-flight prefetch settles (no dedupe)", async () => { + const client = new QueryClient(); + const channelId = "chan-prefetch-race"; + const queryKey = channelMessagesKey(channelId); + let calls = 0; + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const options = { + queryKey, + queryFn: async () => { + calls += 1; + const n = calls; + if (n === 1) await firstGate; + return [event(`fetch-${n}`, 100 + n)]; + }, + staleTime: 300_000, + }; + + // Hover prefetch in flight; a mounted observer dedupes into it. The marker + // is what `prefetchChannelMessages` sets — it is the only thing that tells + // this fetch (already at the relay) apart from a cold mount fetch still + // parked on the hydration gate, which must keep deduping. + markChannelPrefetchStarted(channelId); + const prefetch = client + .prefetchQuery(options) + .finally(() => markChannelPrefetchSettled(channelId)); + const observer = new QueryObserver(client, options); + const unsubscribe = observer.subscribe(() => {}); + + // Live subscription established: the gap refresh MUST NOT adopt the + // prefetched snapshot (fetched before the subscription started). + const refresh = refreshChannelWindowMessages(client, channelId); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseFirst(); + await Promise.allSettled([prefetch, refresh]); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(calls, 2, "gap refresh must issue a second fetch"); + assert.equal(client.getQueryData(queryKey)[0].content, "fetch-2"); + unsubscribe(); + client.clear(); + resetChannelWindowPrefetches(); +}); + +test("the post-subscribe refresh never aborts the fetch the timeline is watching", async () => { + // Hover dwell fires AFTER the click: prefetchQuery does not start a fetch, + // it dedupes onto the mount fetch the user is already watching a skeleton + // for. The prefetch marker cannot tell those apart, so the refresh must + // wait for the in-flight fetch rather than cancel it — cancelling made a + // hovered channel slower to open than an unhovered one. + resetChannelWindowPrefetches(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const channelId = "chan-hover-then-click"; + const queryKey = channelMessagesKey(channelId); + let calls = 0; + let mountSignal = null; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const options = { + queryKey, + queryFn: async ({ signal }) => { + calls += 1; + if (calls === 1) { + mountSignal = signal; + await gate; + } + return [event(`fetch-${calls}`, 100 + calls)]; + }, + staleTime: 300_000, + }; + + // The click mounts the screen and starts the fetch it will paint. + const observer = new QueryObserver(client, options); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // The dwell fires afterwards and merely joins that fetch. + markChannelPrefetchStarted(channelId); + void client + .prefetchQuery(options) + .finally(() => markChannelPrefetchSettled(channelId)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const refresh = refreshChannelWindowMessages(client, channelId); + await new Promise((resolve) => setTimeout(resolve, 10)); + release(); + await Promise.allSettled([refresh]); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal( + mountSignal?.aborted, + false, + "the mounted timeline's own fetch must not be aborted", + ); + unsubscribe(); + client.clear(); + resetChannelWindowPrefetches(); +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index b16187ce18c..4fd924adcbe 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -8,6 +8,7 @@ import { } from "./channelWindowStore"; import { reconcileChannelWindowMessages } from "./channelWindowReconciliation"; import { channelHeadHydration } from "./channelHeadCache"; +import { hasInFlightChannelPrefetch } from "./channelWindowPrefetches"; /** Keep the rendered timeline cache aligned with its authoritative window. */ export function projectChannelWindowMessages( @@ -44,6 +45,16 @@ export async function refreshChannelWindowMessages( query?.state.data !== undefined && query.state.dataUpdatedAt === 0; if (seeded) { await query.promise?.catch(() => {}); + } else if (hasInFlightChannelPrefetch(channelId)) { + // A hover prefetch reached the relay before this subscription existed, so + // its snapshot can miss events that landed in between — the invalidate + // below re-reads the window after the subscription. Wait for the in-flight + // fetch rather than cancelling it: by the time this runs the mounted + // timeline has very likely joined that same fetch (TanStack dedupes onto a + // cold query's in-flight request rather than starting a second one), so + // cancelling would abort the fetch the user is watching a skeleton for and + // make a hovered channel slower to open than an unhovered one. + await query?.promise?.catch(() => {}); } await queryClient.invalidateQueries( { queryKey, exact: true, refetchType: "active" }, diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 09c70f47653..8e27ef671d4 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -18,7 +18,11 @@ import { ProfileAvatarWithStatus, scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { useQueryClient } from "@tanstack/react-query"; + +import { prefetchChannelMessages } from "@/features/messages/hooks"; import type { Channel, PresenceStatus } from "@/shared/api/types"; +import { useHoverIntent } from "@/shared/hooks/useHoverIntent"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { @@ -263,6 +267,13 @@ export function ChannelMenuButton({ }) { const resolvedLabel = label ?? channel.name; const ephemeralDisplay = getEphemeralChannelDisplay(channel); + const queryClient = useQueryClient(); + // Hover intent warms the channel's message window so the click lands on a + // cache hit. Respects the window's staleTime — re-hovering a fresh channel + // never refetches. + const hoverPrefetch = useHoverIntent(() => + prefetchChannelMessages(queryClient, channel), + ); const { hasSidebarUnreadProjections, topLevelUnreadChannelIds, @@ -310,6 +321,8 @@ export function ChannelMenuButton({ data-testid={`channel-${channel.name}`} isActive={isActive} onClick={() => onSelectChannel(channel.id)} + onMouseEnter={hoverPrefetch.onMouseEnter} + onMouseLeave={hoverPrefetch.onMouseLeave} tooltip={resolvedLabel} type="button" > diff --git a/desktop/src/shared/hooks/useHoverIntent.test.mjs b/desktop/src/shared/hooks/useHoverIntent.test.mjs new file mode 100644 index 00000000000..a64ee2d484b --- /dev/null +++ b/desktop/src/shared/hooks/useHoverIntent.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createHoverIntent } from "./useHoverIntent.ts"; + +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + return { + setTimeout: (fn, _ms) => { + const id = nextId++; + timers.set(id, fn); + return id; + }, + clearTimeout: (id) => timers.delete(id), + fire: () => { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + pending: () => timers.size, + }; +} + +test("fires the callback only after the dwell elapses", () => { + const timers = fakeTimers(); + let fired = 0; + const intent = createHoverIntent(() => fired++, timers); + + intent.start(); + assert.equal(fired, 0); + timers.fire(); + assert.equal(fired, 1); +}); + +test("leaving before the dwell cancels the callback", () => { + const timers = fakeTimers(); + let fired = 0; + const intent = createHoverIntent(() => fired++, timers); + + intent.start(); + intent.cancel(); + timers.fire(); + assert.equal(fired, 0); + assert.equal(timers.pending(), 0); +}); + +test("re-entering restarts the dwell without stacking timers", () => { + const timers = fakeTimers(); + let fired = 0; + const intent = createHoverIntent(() => fired++, timers); + + intent.start(); + intent.start(); + assert.equal(timers.pending(), 1); + timers.fire(); + assert.equal(fired, 1); +}); diff --git a/desktop/src/shared/hooks/useHoverIntent.ts b/desktop/src/shared/hooks/useHoverIntent.ts new file mode 100644 index 00000000000..9e22a31e61b --- /dev/null +++ b/desktop/src/shared/hooks/useHoverIntent.ts @@ -0,0 +1,68 @@ +import * as React from "react"; + +/** + * Dwell before a hover counts as intent. Long enough that scrubbing the + * pointer across the sidebar never fires, short enough that a deliberate + * hover warms the destination well before the click lands. + */ +const HOVER_INTENT_DWELL_MS = 100; + +type TimerHost = { + setTimeout: (fn: () => void, ms: number) => number; + clearTimeout: (id: number) => void; +}; + +/** + * Pure dwell-timer core behind {@link useHoverIntent}; injectable timers for + * unit testing. `start` restarts the dwell; `cancel` drops it. + */ +export function createHoverIntent( + onIntent: () => void, + timers: TimerHost, + dwellMs: number = HOVER_INTENT_DWELL_MS, +): { start: () => void; cancel: () => void } { + let timerId: number | null = null; + const cancel = () => { + if (timerId !== null) { + timers.clearTimeout(timerId); + timerId = null; + } + }; + return { + start: () => { + cancel(); + timerId = timers.setTimeout(() => { + timerId = null; + onIntent(); + }, dwellMs); + }, + cancel, + }; +} + +/** + * Fires `onIntent` after the pointer dwells on an element. Returns stable + * mouse-enter/leave handlers; the latest callback is always used, and any + * pending dwell is dropped on unmount. + */ +export function useHoverIntent(onIntent: () => void): { + onMouseEnter: () => void; + onMouseLeave: () => void; +} { + const callbackRef = React.useRef(onIntent); + callbackRef.current = onIntent; + const intentRef = React.useRef | null>( + null, + ); + if (intentRef.current === null) { + intentRef.current = createHoverIntent(() => callbackRef.current(), { + setTimeout: (fn, ms) => window.setTimeout(fn, ms), + clearTimeout: (id) => window.clearTimeout(id), + }); + } + React.useEffect(() => () => intentRef.current?.cancel(), []); + return { + onMouseEnter: intentRef.current.start, + onMouseLeave: intentRef.current.cancel, + }; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eb74c47d84e..3dd0d80c974 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5620,20 +5620,39 @@ async function handleGetChannelWindow( const probe = window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number; + __CHANNEL_WINDOW_HEAD_FETCH_COUNT__?: number; + __CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__?: number; __CHANNEL_WINDOW_INFLIGHT__?: number; __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number; }; - if (args.cursor !== null) { + const isHead = args.cursor === null; + if (isHead) { + // TEST-ONLY probe: head (cursorless) window fetches, keyed for specs that + // assert prefetch behavior. Continuations keep their own counter below. + probe.__CHANNEL_WINDOW_HEAD_FETCH_COUNT__ = + (probe.__CHANNEL_WINDOW_HEAD_FETCH_COUNT__ ?? 0) + 1; + } else { probe.__CHANNEL_WINDOW_FETCH_COUNT__ = (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; } - const delayMs = - args.cursor === null - ? (getConfig()?.mock?.channelHeadDelayMs ?? 0) - : (getConfig()?.mock?.channelWindowDelayMs ?? 0); + // Completion counter: specs asserting a warmed cache must wait for this, not + // the start counter — a started-but-pending prefetch proves nothing. Counted + // on every head path, delayed or not. + const run = async () => { + const result = await execute(); + if (isHead) { + probe.__CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__ = + (probe.__CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__ ?? 0) + 1; + } + return result; + }; + + const delayMs = isHead + ? (getConfig()?.mock?.channelHeadDelayMs ?? 0) + : (getConfig()?.mock?.channelWindowDelayMs ?? 0); if (delayMs <= 0) { - return execute(); + return run(); } probe.__CHANNEL_WINDOW_INFLIGHT__ = @@ -5644,7 +5663,7 @@ async function handleGetChannelWindow( ); await new Promise((resolve) => window.setTimeout(resolve, delayMs)); try { - return await execute(); + return await run(); } finally { probe.__CHANNEL_WINDOW_INFLIGHT__ = (probe.__CHANNEL_WINDOW_INFLIGHT__ ?? 1) - 1; diff --git a/desktop/tests/e2e/sidebar-hover-prefetch.spec.ts b/desktop/tests/e2e/sidebar-hover-prefetch.spec.ts new file mode 100644 index 00000000000..b3a0392d662 --- /dev/null +++ b/desktop/tests/e2e/sidebar-hover-prefetch.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Sidebar hover intent must warm the hovered channel's message window before + * the click: dwelling on an unvisited channel row triggers exactly one + * window fetch, so the subsequent click paints from cache instead of paying + * the fetch on the switch path. Scrubbing across the row (enter → quick + * leave) must NOT fetch. + */ + +declare global { + interface Window { + __CHANNEL_WINDOW_HEAD_FETCH_COUNT__?: number; + __CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__?: number; + } +} + +async function windowFetchCount(page: import("@playwright/test").Page) { + return page.evaluate(() => window.__CHANNEL_WINDOW_HEAD_FETCH_COUNT__ ?? 0); +} + +async function windowCompleteCount(page: import("@playwright/test").Page) { + return page.evaluate( + () => window.__CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__ ?? 0, + ); +} + +test("hover dwell prefetches the channel window; scrubbing does not", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + // Seed #random (empty by default) so the warmed cache has a row to paint; + // recordMockMessage writes to the store without needing a subscription. + await page.evaluate(() => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Prefetched row", + createdAt: Math.floor(Date.now() / 1000) - 300, + }); + }); + const baseline = await windowFetchCount(page); + + // Scrub: enter and leave immediately — under the dwell, no fetch. + const random = page.getByTestId("channel-random"); + await random.hover(); + await page.getByTestId("channel-general").hover({ force: true }); + await page.getByTestId("app-sidebar").hover({ position: { x: 4, y: 4 } }); + await page.waitForTimeout(300); + const afterScrub = await windowFetchCount(page); + + // Dwell: hover and stay past the intent threshold — exactly one fetch for + // the hovered channel, before any click. + const completedBeforeDwell = await windowCompleteCount(page); + await random.hover(); + await expect + .poll(() => windowFetchCount(page), { timeout: 2_000 }) + .toBe(afterScrub + 1); + + // The warmed cache only exists once the prefetch COMPLETES — a pending or + // failed prefetch must not pass this spec. + await expect + .poll(() => windowCompleteCount(page), { timeout: 2_000 }) + .toBeGreaterThan(completedBeforeDwell); + + // The click then paints the timeline from the warmed cache: rows are + // visible, not just the header. The subscription-gap refresh may add its + // own fetch after mount; the paint itself must not wait on one. + await random.click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-row").first()).toBeVisible(); + + // Scrubbing earlier must not have fetched anything beyond the baseline. + expect(afterScrub).toBe(baseline); +});