Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 45 additions & 8 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelayEvent[]>(queryKey) ?? [];
Expand All @@ -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),
});
}

Expand Down
39 changes: 39 additions & 0 deletions desktop/src/features/messages/lib/projectChannelWindow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
10 changes: 10 additions & 0 deletions desktop/src/features/messages/lib/projectChannelWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/sidebar/ui/SidebarSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
>
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/shared/hooks/useHoverIntent.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
68 changes: 68 additions & 0 deletions desktop/src/shared/hooks/useHoverIntent.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createHoverIntent> | 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,
};
}
15 changes: 14 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading