Skip to content
Closed
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 @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -58,6 +59,7 @@ async function resetCommunityState({
resetAvatarState: boolean;
}): Promise<void> {
relayClient.disconnect();
resetChannelWindowPrefetches();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
Expand Down
57 changes: 50 additions & 7 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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),
});
}

Expand Down
28 changes: 28 additions & 0 deletions desktop/src/features/messages/lib/channelWindowPrefetches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const inFlight = new Set<string>();

/**
* 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();
}
110 changes: 110 additions & 0 deletions desktop/src/features/messages/lib/projectChannelWindow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});
11 changes: 11 additions & 0 deletions desktop/src/features/messages/lib/projectChannelWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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" },
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 @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
>
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);
});
Loading
Loading