diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 40631e2672..b4a4001b51 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -467,3 +467,62 @@ test("gap refresh refetches after an in-flight prefetch settles (no dedupe)", as 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 e96a22830d..4fd924adcb 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -47,11 +47,14 @@ export async function refreshChannelWindowMessages( 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. Cancel it so the - // invalidate below issues a fetch that starts after this refresh; a cold - // mount fetch is still parked on the hydration gate above, carries no such - // gap, and keeps deduping. - await queryClient.cancelQueries({ queryKey, exact: true }); + // 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" },