diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..1bfd3d312b3 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.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/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index aa384101adc..b4bc0555f93 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -261,14 +261,26 @@ export function reconcileFetchedChannelWindow( return reconcileChannelWindowMessages(next, previousMessages); } -export function useChannelMessagesQuery(channel: Channel | null) { - const queryClient = useQueryClient(); - const queryKey = channelMessagesKey(channel?.id ?? "none"); +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; - return useQuery({ - enabled: channel !== null && channel.channelType !== "forum", +/** + * 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 { queryKey, - queryFn: async ({ signal }) => { + queryFn: async ({ signal }: { signal: AbortSignal }) => { if (!channel) throw new Error("No channel selected."); const previousMessages = queryClient.getQueryData(queryKey) ?? []; @@ -288,8 +300,33 @@ 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; + void queryClient.prefetchQuery( + channelMessagesQueryOptions(queryClient, channel), + ); +} + +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/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 14ec110addf..6bd7611307a 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -363,3 +363,42 @@ test("test_pageless_live_projection_preserves_cached_timeline", () => { assert.deepEqual(contents(harness), ["initial", "live"]); assert.equal(harness.client.getQueryData(harness.messagesKey)[0], cached[0]); }); + +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. + const prefetch = client.prefetchQuery(options); + 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(); +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index 81ef3de42d0..9b48596d3f9 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -26,6 +26,16 @@ export async function refreshChannelWindowMessages( queryClient: QueryClient, channelId: string, ) { + // A hover prefetch may still be in flight when the live subscription + // establishes. TanStack dedupes into an in-flight initial fetch, so + // invalidating alone can adopt a snapshot fetched BEFORE the subscription + // started and drop any event that landed in between. Cancel the in-flight + // fetch first — matching the canceled-stale-fetch contract — so the + // invalidate below always issues a fetch that starts after this refresh. + await queryClient.cancelQueries({ + queryKey: channelMessagesKey(channelId), + exact: true, + }); await queryClient.invalidateQueries({ queryKey: channelMessagesKey(channelId), exact: true, diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24d..62fdefa5951 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -25,7 +25,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 { @@ -274,6 +278,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, @@ -313,6 +324,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 2452b1ce12b..4b879f4e901 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5579,7 +5579,20 @@ async function handleGetChannelWindow( }; if (!args.cursor) { - return execute(); + // TEST-ONLY probe: head (cursorless) window fetches, keyed for specs that + // assert prefetch behavior. Continuations keep their own counter below. + const headProbe = window as unknown as { + __CHANNEL_WINDOW_HEAD_FETCH_COUNT__?: number; + __CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__?: number; + }; + headProbe.__CHANNEL_WINDOW_HEAD_FETCH_COUNT__ = + (headProbe.__CHANNEL_WINDOW_HEAD_FETCH_COUNT__ ?? 0) + 1; + const result = await execute(); + // Completion counter: specs asserting a warmed cache must wait for this, + // not the start counter — a started-but-pending prefetch proves nothing. + headProbe.__CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__ = + (headProbe.__CHANNEL_WINDOW_HEAD_COMPLETE_COUNT__ ?? 0) + 1; + return result; } const probe = window as unknown as { 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); +});