From f0335c3b1859712818004b1cf0e4dfb810081445 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 15:07:07 -0600 Subject: [PATCH 1/4] fix(desktop): keep history pagination anchored through visual commit Reserve one older-page transaction through deferred rendering and stable measurement. Admit rows and metadata together, reconcile Virtua sizes by key, and stage reconnect refreshes without evicting the reading window. Add recovery controls, concurrent-send rollback protection and mounted/browser regressions for reader intent, cancellation, mixed updates and late reflow. Co-authored-by: Wes Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Signed-off-by: Wes --- desktop/playwright.config.ts | 1 + .../src/features/channels/ui/ChannelPane.tsx | 34 +- .../features/channels/ui/ChannelPane.types.ts | 4 +- .../features/channels/ui/ChannelScreen.tsx | 6 +- .../ui/useChannelPaneDmParticipants.ts | 44 + .../channels/ui/useHuddleChannelMessages.ts | 26 +- desktop/src/features/messages/hooks.ts | 113 ++- .../lib/channelWindowReconciliation.ts | 24 +- .../messages/lib/channelWindowStore.ts | 13 + .../messages/lib/pageOlderMessages.ts | 12 +- .../lib/projectChannelWindow.test.mjs | 108 +++ .../lib/renderScopedReactions.test.mjs | 43 +- .../messages/lib/renderScopedReactions.ts | 21 +- .../lib/revalidateChannelWindow.test.mjs | 310 +++++++ .../messages/lib/revalidateChannelWindow.ts | 100 +++ .../messages/lib/timelineSnapshot.test.mjs | 14 +- .../features/messages/lib/timelineSnapshot.ts | 2 +- .../messages/ui/HistoryRefreshNotice.tsx | 90 ++ .../features/messages/ui/MessageTimeline.tsx | 203 ++--- .../messages/ui/TimelineMessageList.tsx | 31 +- .../ui/historyPaginationLifecycle.test.mjs | 828 ++++++++++++++++++ .../ui/useAdmittedTimelineSnapshot.ts | 51 ++ .../messages/ui/useHistoryBoundaryIntent.ts | 143 +++ .../messages/ui/useHistoryPagination.ts | 158 ++++ .../messages/ui/useLoadOlderOnScroll.ts | 2 +- .../ui/useSettleGatedPrependMessages.test.mjs | 8 +- .../ui/useSettleGatedPrependMessages.ts | 60 +- .../messages/ui/useUpwardPaginationWheel.ts | 20 +- .../messages/ui/virtuaKeyedPatch.test.mjs | 227 +++++ .../messages/ui/virtuaWheelModePatch.test.mjs | 7 +- .../messages/useFetchOlderMessages.ts | 72 +- .../e2e/channel-dense-second-reach.spec.ts | 68 +- .../tests/e2e/history-transactions.spec.ts | 400 +++++++++ desktop/tests/e2e/scroll-history.spec.ts | 129 +-- desktop/tests/e2e/virtualization.spec.ts | 21 +- desktop/tests/helpers/timelineHistory.ts | 42 + patches/virtua@0.49.3.patch | 323 ++++++- pnpm-lock.yaml | 6 +- 38 files changed, 3291 insertions(+), 473 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelPaneDmParticipants.ts create mode 100644 desktop/src/features/messages/lib/revalidateChannelWindow.test.mjs create mode 100644 desktop/src/features/messages/lib/revalidateChannelWindow.ts create mode 100644 desktop/src/features/messages/ui/HistoryRefreshNotice.tsx create mode 100644 desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs create mode 100644 desktop/src/features/messages/ui/useAdmittedTimelineSnapshot.ts create mode 100644 desktop/src/features/messages/ui/useHistoryBoundaryIntent.ts create mode 100644 desktop/src/features/messages/ui/useHistoryPagination.ts create mode 100644 desktop/src/features/messages/ui/virtuaKeyedPatch.test.mjs create mode 100644 desktop/tests/e2e/history-transactions.spec.ts create mode 100644 desktop/tests/helpers/timelineHistory.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..beb36b1836b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -103,6 +103,7 @@ export default defineConfig({ "**/reminder-click-repro.spec.ts", "**/virtualization.spec.ts", "**/scroll-history.spec.ts", + "**/history-transactions.spec.ts", "**/channel-dense-second-reach.spec.ts", "**/channel-window-mock-paging.spec.ts", "**/channel-head-restart.spec.ts", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a4..a17a75ab816 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -18,11 +18,7 @@ import { MessageTimeline, type MessageTimelineHandle, } from "@/features/messages/ui/MessageTimeline"; -import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; -import { - getDmHuddleMemberPubkeys, - hasOtherDmParticipant, -} from "@/features/channels/lib/dmHuddleMembers"; +import { useChannelPaneDmParticipants } from "./useChannelPaneDmParticipants"; import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; @@ -87,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, editTarget = null, fetchOlder, + historyRevision, header, idleAuxiliaryPanel = null, idleAuxiliaryHeaderActions, @@ -221,12 +218,17 @@ export const ChannelPane = React.memo(function ChannelPane({ void goChannel(activeChannelId, { replace: true }); } }, [activeChannelId, goChannel, onAutoSendComplete]); - const huddleMemberPubkeys = React.useMemo( - () => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey), - [activeChannel, agentPubkeys, currentPubkey], - ); - const huddleMemberPubkeysPending = - agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); + const { + directMessageIntro, + huddleMemberPubkeys, + huddleMemberPubkeysPending, + } = useChannelPaneDmParticipants({ + activeChannel, + agentPubkeys, + agentPubkeysPending, + currentPubkey, + profiles, + }); const isActiveWelcomeChannel = activeChannel !== null && isWelcomeExperience(activeChannel); useComposerHeightPadding( @@ -348,15 +350,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [botTypingEntries, openThreadHeadId]); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; - const directMessageIntro = React.useMemo( - () => - buildDirectMessageIntro({ - channel: activeChannel, - currentPubkey, - profiles, - }), - [activeChannel, currentPubkey, profiles], - ); const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -631,6 +624,7 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollContainerRef={timelineScrollRef} currentPubkey={currentPubkey} fetchOlder={fetchOlder} + historyRevision={historyRevision} followThreadById={followThreadById} hasComposerOverlay={hasMainComposerOverlay} hasOlderMessages={hasOlderMessages} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5bce6a71ff7..65be02ee8b3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -38,7 +38,9 @@ export type ChannelPaneProps = { channelManagementOpen?: boolean; currentPubkey?: string; editTarget?: MessageComposerEditTarget | null; - fetchOlder?: () => Promise; + fetchOlder?: () => Promise; + /** Authoritative history publication paired with the message snapshot. */ + historyRevision?: number; header?: React.ReactNode; /** * Idle-state body for the right auxiliary pane (project extras, etc.). diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7cf..57a954cb0c5 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -210,7 +210,7 @@ export function ChannelScreen({ effectiveOpenThreadHeadId, ); useChannelSubscription(activeChannel); - const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } = + const { fetchOlder, isFetchingOlder } = useFetchOlderMessages(activeChannel); const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; @@ -257,8 +257,7 @@ export function ChannelScreen({ const editMessageMutation = useEditMessageMutation(activeChannel); const joinChannelMutation = useJoinChannelMutation(activeChannelId); const { - resolvedMessages, - threadSummaries, + resolvedMessages, threadSummaries, historyRevision, historyExhausted, hasOlderMessages, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, } = useHuddleChannelMessages({ @@ -859,6 +858,7 @@ export function ChannelScreen({ currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} + historyRevision={historyRevision} header={channelHeader} {...{ idleAuxiliaryHeaderActions, idleAuxiliaryOverridesThread, idleAuxiliaryPanel, idleAuxiliaryTitle, hasOlderMessages, historyExhausted }} {...{ onAddFiles }} diff --git a/desktop/src/features/channels/ui/useChannelPaneDmParticipants.ts b/desktop/src/features/channels/ui/useChannelPaneDmParticipants.ts new file mode 100644 index 00000000000..2d9fc293ecc --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelPaneDmParticipants.ts @@ -0,0 +1,44 @@ +import * as React from "react"; +import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; +import { + getDmHuddleMemberPubkeys, + hasOtherDmParticipant, +} from "@/features/channels/lib/dmHuddleMembers"; +import type { ChannelPaneProps } from "./ChannelPane.types"; + +/** Derive the shared DM intro and huddle participants without changing identity. */ +export function useChannelPaneDmParticipants({ + activeChannel, + agentPubkeys, + agentPubkeysPending, + currentPubkey, + profiles, +}: Pick< + ChannelPaneProps, + | "activeChannel" + | "agentPubkeys" + | "agentPubkeysPending" + | "currentPubkey" + | "profiles" +>) { + const huddleMemberPubkeys = React.useMemo( + () => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey), + [activeChannel, agentPubkeys, currentPubkey], + ); + const huddleMemberPubkeysPending = + agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); + const directMessageIntro = React.useMemo( + () => + buildDirectMessageIntro({ + channel: activeChannel, + currentPubkey, + profiles, + }), + [activeChannel, currentPubkey, profiles], + ); + return { + directMessageIntro, + huddleMemberPubkeys, + huddleMemberPubkeysPending, + }; +} diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index 5a3a7c40419..228e5ae3579 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -4,8 +4,11 @@ import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { mergeMessages } from "@/features/messages/hooks"; import { channelWindowThreadSummaries, + channelWindowHasMore, + channelWindowHistoryExhausted, type ChannelWindowStore, } from "@/features/messages/lib/channelWindowStore"; +import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation"; import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -32,11 +35,21 @@ export function useHuddleChannelMessages({ targetMessageEvents, windowStore, }: HuddleChannelMessagesOptions) { + // Query observers may notify separately. Project from the SAME store that + // supplies the receipt and structural metadata; never pair a new revision + // with an older channelMessages cache notification. + const windowMessages = React.useMemo( + () => + windowStore + ? reconcileChannelWindowMessages(windowStore, messages) + : messages, + [messages, windowStore], + ); const resolvedChannelMessages = React.useMemo(() => { - const extraEvents = targetMessageEvents; - if (!activeChannel || extraEvents.length === 0) return messages; - return extraEvents.reduce(mergeMessages, messages); - }, [activeChannel, messages, targetMessageEvents]); + if (!activeChannel || targetMessageEvents.length === 0) + return windowMessages; + return targetMessageEvents.reduce(mergeMessages, windowMessages); + }, [activeChannel, windowMessages, targetMessageEvents]); const threadSummaries = React.useMemo( () => (windowStore ? channelWindowThreadSummaries(windowStore) : new Map()), @@ -69,6 +82,11 @@ export function useHuddleChannelMessages({ return { resolvedMessages, threadSummaries, + historyRevision: windowStore?.revision ?? 0, + hasOlderMessages: windowStore ? channelWindowHasMore(windowStore) : false, + historyExhausted: windowStore + ? channelWindowHistoryExhausted(windowStore) + : false, // A summarized reply subtree failing must not leave the transcript reading // as complete: surface the aggregate failure so the consumer can show a // non-destructive retry alert alongside the rows that did load. diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index d28f2926081..f4b30e6eb55 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,3 +1,8 @@ +import { + appendOlderChannelWindow, + type ChannelWindowPage, +} from "./lib/channelWindowStore"; +import { revalidateChannelWindow } from "./lib/revalidateChannelWindow"; import { useEffect, useEffectEvent } from "react"; import { type QueryClient, @@ -82,8 +87,6 @@ import { type MessageQueryContext = { optimisticId: string; - previousMessages: RelayEvent[]; - previousWindow: ChannelWindowStore | undefined; channelId: string; queryKey: ReturnType; }; @@ -263,6 +266,8 @@ export function reconcileFetchedChannelWindow( events: Awaited>, previousMessages: RelayEvent[], signal: AbortSignal, + revalidatedPages?: ChannelWindowPage[], + retainedBeforeFetch?: ChannelWindowStore, ): RelayEvent[] { // Tauri invokes cannot be canceled after dispatch. A replacement refetch can // therefore win while this older request is still in flight. Never let that @@ -273,7 +278,24 @@ export function reconcileFetchedChannelWindow( const current = queryClient.getQueryData(windowKey) ?? emptyChannelWindowStore(); - const next = replaceNewestChannelWindow(current, page); + let next = replaceNewestChannelWindow(current, page); + for (const older of revalidatedPages?.slice(1) ?? []) { + next = appendOlderChannelWindow(next, older); + } + if (retainedBeforeFetch) { + const freshIds = new Set( + (revalidatedPages ?? [page]) + .filter((candidate) => !current.pages.includes(candidate)) + .flatMap((candidate) => candidate.rows.map((row) => row.event.id)), + ); + next.liveSummaries = Object.fromEntries( + Object.entries(current.liveSummaries).filter( + ([id, summary]) => + !freshIds.has(id) || + summary !== retainedBeforeFetch.liveSummaries[id], + ), + ); + } queryClient.setQueryData(windowKey, next); const scope = channelHeadCacheScope(queryClient); if (scope) { @@ -301,14 +323,57 @@ export function useChannelMessagesQuery(channel: Channel | null) { } const previousMessages = queryClient.getQueryData(queryKey) ?? []; - const events = await getChannelWindowEvents(channel.id); - return reconcileFetchedChannelWindow( - queryClient, - channel.id, - events, - previousMessages, - signal, - ); + const retained = + queryClient.getQueryData( + channelWindowKey(channel.id), + ) ?? emptyChannelWindowStore(); + try { + const events = await getChannelWindowEvents(channel.id); + return await revalidateChannelWindow({ + head: parseChannelWindowResponse(events, channel.id, null), + retained: retained.refreshLatestOnly + ? emptyChannelWindowStore() + : retained, + readCurrent: () => + retained.refreshLatestOnly + ? emptyChannelWindowStore() + : (queryClient.getQueryData( + channelWindowKey(channel.id), + ) ?? retained), + fetchPage: async (cursor, limitRows) => + parseChannelWindowResponse( + await getChannelWindowEvents(channel.id, cursor, limitRows), + channel.id, + cursor, + ), + signal, + publish: (pages) => + reconcileFetchedChannelWindow( + queryClient, + channel.id, + events, + queryClient.getQueryData(queryKey) ?? + previousMessages, + signal, + pages, + retained, + ), + }); + } catch (error) { + if (!signal.aborted) { + queryClient.setQueryData( + channelWindowKey(channel.id), + (current) => ({ + ...(current ?? retained), + refreshLatestOnly: false, + refreshError: (current ?? retained).pages.length + ? "Couldn’t refresh messages. Your loaded history is still available." + : "Couldn’t load messages. Try again.", + }), + ); + } + throw error; + } }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, @@ -684,9 +749,9 @@ export function useSendMessageMutation( const queryKey = channelMessagesKey(effectiveChannel.id); const windowKey = channelWindowKey(effectiveChannel.id); - // The rendered timeline is projected from the channel-window cache. Cancel - // both reads before snapshotting either cache so an older window response - // cannot replace the optimistic row between onMutate and onSuccess. + // Cancel active query refetches before adding the optimistic row. Older + // paging and live writes can still commit afterward; rollback must remove + // only this operation, never restore these snapshots wholesale. await Promise.all([ queryClient.cancelQueries({ queryKey }), queryClient.cancelQueries({ queryKey: windowKey }), @@ -717,8 +782,6 @@ export function useSendMessageMutation( return { optimisticId: optimisticMessage.id, - previousMessages, - previousWindow, channelId: effectiveChannel.id, queryKey, }; @@ -732,11 +795,23 @@ export function useSendMessageMutation( return; } - queryClient.setQueryData(context.queryKey, context.previousMessages); - queryClient.setQueryData( + queryClient.setQueryData( channelWindowKey(context.channelId), - context.previousWindow, + (current) => + current + ? { + ...current, + liveOverlay: current.liveOverlay.filter( + (event) => event.id !== context.optimisticId, + ), + } + : current, ); + // Remove the cache-only pending copy too, or projection would retain it. + queryClient.setQueryData(context.queryKey, (current = []) => + current.filter((event) => event.id !== context.optimisticId), + ); + projectChannelWindowMessages(queryClient, context.channelId); }, onSuccess: (message, _variables, context) => { // An accepted send proves the write-block is lifted; clear any recorded diff --git a/desktop/src/features/messages/lib/channelWindowReconciliation.ts b/desktop/src/features/messages/lib/channelWindowReconciliation.ts index f6a7e4df21f..789be72106d 100644 --- a/desktop/src/features/messages/lib/channelWindowReconciliation.ts +++ b/desktop/src/features/messages/lib/channelWindowReconciliation.ts @@ -6,6 +6,7 @@ import { type ChannelWindowStore, } from "./channelWindowStore"; import { reconcileIncomingMessage } from "./messageMerge"; +import { dedupeMessagesById } from "./messageQueryKeys"; import { getThreadReference, isBroadcastReply } from "./threading"; const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); @@ -41,22 +42,31 @@ export function reconcileChannelWindowMessages( return [...merged].sort((left, right) => compareRelayOrder(right, left)); } const authoritativeIds = new Set(windowEvents.map((event) => event.id)); - const retained = retainRefetchReconciliationEvents(messages).filter( - (event) => !authoritativeIds.has(event.id), - ); + const retained = dedupeMessagesById( + retainRefetchReconciliationEvents(messages), + ).filter((event) => !authoritativeIds.has(event.id)); // Reconcile acknowledgements against cache-only rows without changing the // authoritative window's order. The render key moves from an optimistic row // to its relay acknowledgement while the relay row remains in its original // cursor position. - let cacheOnly = retained; + // Only pending sends can match acknowledgements. Do not repeatedly dedupe + // and scan all cached thread replies for each retained history row: that is + // quadratic in scrollback depth on every live event. + let pending = retained.filter((event) => event.pending); const authoritative = windowEvents.map((event) => { - const reconciled = reconcileIncomingMessage(cacheOnly, event); + if (pending.length === 0) return event; + const reconciled = reconcileIncomingMessage(pending, event); const incoming = reconciled.at(-1); - cacheOnly = reconciled.slice(0, -1); + pending = reconciled.slice(0, -1); return incoming ?? event; }); - + const authoritativeKeys = new Set( + authoritative.map((event) => event.localKey ?? event.id), + ); + const cacheOnly = retained.filter( + (event) => !authoritativeKeys.has(event.localKey ?? event.id), + ); return mergeChronologicalMessages(cacheOnly, authoritative); } diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 1bd921aabf4..70ee1a887ae 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -24,7 +24,15 @@ export type ChannelWindowPage = { hasMore: boolean; }; export type ChannelWindowStore = { + /** Monotonic publication token; changes only when authoritative pages commit. */ + revision: number; + /** Retires older requests when an authoritative head replaces their chain. */ + generation: number; pages: ChannelWindowPage[]; + /** A recoverable head refresh failure; retained history remains readable. */ + refreshError?: string; + /** Explicit recovery request; retire retained history only after a new head succeeds. */ + refreshLatestOnly?: boolean; /** Top-level live events not represented in an authoritative relay page. */ liveOverlay: RelayEvent[]; /** Live structural events retained independently from frozen page closure. */ @@ -37,6 +45,8 @@ export type ChannelWindowStore = { }; export const emptyChannelWindowStore = (): ChannelWindowStore => ({ + revision: 0, + generation: 0, pages: [], liveOverlay: [], liveAux: [], @@ -111,6 +121,8 @@ export function replaceNewestChannelWindow( const ids = new Set(page.rows.map((row) => row.event.id)); const auxIds = new Set(page.aux.map((event) => event.id)); return { + revision: current.revision + 1, + generation: current.generation + 1, pages: [page], liveOverlay: current.liveOverlay.filter((event) => !ids.has(event.id)), liveAux: current.liveAux.filter((event) => !auxIds.has(event.id)), @@ -150,6 +162,7 @@ export function appendOlderChannelWindow( const pageIds = new Set(page.rows.map((row) => row.event.id)); return { ...current, + revision: current.revision + 1, pages: [...current.pages, page], liveOverlay: current.liveOverlay.filter((event) => !pageIds.has(event.id)), }; diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index af51a19348c..fb49ce102b6 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -10,7 +10,11 @@ import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; import { getChannelWindowEvents } from "@/shared/api/channelWindow"; const CHANNEL_WINDOW_PAGE_SIZE = 50; -export type PageOlderResult = { hasOlderMessages: boolean }; +export type PageOlderResult = { + hasOlderMessages: boolean; + /** Receipt for the exact publication the timeline must visually commit. */ + revision?: number; +}; const inFlightPasses = new Map>(); /** Fetch exactly one server-defined older window and append it atomically. */ @@ -52,9 +56,11 @@ async function runPage( const retained = queryClient.getQueryData( channelWindowKey(channelId), ); - if (!retained) return { hasOlderMessages: true }; + if (!retained || retained.generation !== store.generation) { + return { hasOlderMessages: true }; + } const next = appendOlderChannelWindow(retained, page); queryClient.setQueryData(channelWindowKey(channelId), next); projectChannelWindowMessages(queryClient, channelId); - return { hasOlderMessages: page.hasMore }; + return { hasOlderMessages: page.hasMore, revision: next.revision }; } diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc9912..e48711282fa 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -416,3 +416,111 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("projection does not rescan cached thread replies per authoritative row", () => { + const rows = Array.from({ length: 1000 }, (_, index) => + event(`row-${String(index).padStart(4, "0")}`, 2000 - index), + ); + let reads = 0; + const replies = Array.from({ length: 300 }, (_, index) => ({ + ...event(`reply-${index}`, 1000 + index), + get id() { + reads++; + return `reply-${index}`; + }, + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + })); + const window = replaceNewestChannelWindow( + emptyChannelWindowStore(), + newestPage(rows), + ); + const result = reconcileChannelWindowMessages(window, replies); + assert.equal(result.length, 1300); + assert.ok( + reads < 3000, + `cache-only id reads ${reads} must be linear, not 300 × 1000`, + ); +}); + +test("staged refresh keeps live summaries for reused tails and updates received during fetch", () => { + const harness = createHarness(); + const fresh = event("fresh", 200); + const reused = event("reused", 100); + const cursor = { createdAt: fresh.created_at, eventId: fresh.id }; + const head = { ...newestPage([fresh]), hasMore: true, nextCursor: cursor }; + const tail = { ...newestPage([reused]), startCursor: cursor }; + const live = (count) => ({ + summary: { + replyCount: count, + descendantCount: count, + participantPubkeys: [], + lastReplyAt: 300, + }, + createdAt: count, + }); + const oldHeadSummary = live(1); + const oldTailSummary = live(2); + const retained = { + ...appendOlderChannelWindow( + replaceNewestChannelWindow(emptyChannelWindowStore(), head), + tail, + ), + liveSummaries: { [fresh.id]: oldHeadSummary, [reused.id]: oldTailSummary }, + }; + harness.client.setQueryData(harness.windowKey, retained); + // QueryClient structural sharing can change identity; take the exact + // retained pages used by production rather than assuming reference equality. + const current = harness.client.getQueryData(harness.windowKey); + const wire = wirePage([fresh]); + wire.at(-1).content = JSON.stringify({ + has_more: true, + next_cursor: { created_at: 200, id: fresh.id }, + }); + reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + wire, + [], + new AbortController().signal, + [head, current.pages[1]], + current, + ); + let summaries = harness.client.getQueryData(harness.windowKey).liveSummaries; + assert.equal( + summaries[fresh.id], + undefined, + "fresh head supersedes pre-fetch summary", + ); + assert.deepEqual( + summaries[reused.id], + oldTailSummary, + "reused tail is not a fresh recount", + ); + + harness.client.setQueryData(harness.windowKey, retained); + const beforeFetch = harness.client.getQueryData(harness.windowKey); + harness.client.setQueryData(harness.windowKey, { + ...beforeFetch, + liveSummaries: { ...beforeFetch.liveSummaries, [fresh.id]: live(3) }, + }); + const duringFetch = harness.client.getQueryData(harness.windowKey); + reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + wire, + [], + new AbortController().signal, + [head, duringFetch.pages[1]], + beforeFetch, + ); + summaries = harness.client.getQueryData(harness.windowKey).liveSummaries; + assert.deepEqual( + summaries[fresh.id], + live(3), + "in-flight push survives fresh page publication", + ); + harness.client.clear(); +}); diff --git a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs index 6429ecf8116..0961709e01d 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs +++ b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs @@ -9,7 +9,13 @@ import { resetRenderScopedReactionHydration, } from "./renderScopedReactions.ts"; import { formatTimelineMessages } from "./formatTimelineMessages.ts"; -import { channelMessagesKey } from "./messageQueryKeys.ts"; +import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; + +import { + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "./channelWindowStore.ts"; +import { projectChannelWindowMessages } from "./projectChannelWindow.ts"; const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -154,6 +160,41 @@ test("hydrates visible reactions into the channel timeline cache", async () => { ); }); +test("hydration-only reactions survive a subsequent authoritative page projection", async () => { + const message = event(hex("1"), 9); + const reaction = event(hex("2"), 7, { + content: "✅", + tags: [["e", message.id]], + }); + const queryClient = makeQueryClientStub([message]); + queryClient.setQueryData( + channelWindowKey(CHANNEL_ID), + replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: message, thread: null }], + aux: [], + nextCursor: null, + hasMore: false, + }), + ); + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [message.id], + queryClient, + deps: { fetchReactionEventsForMessages: async () => [reaction] }, + }); + projectChannelWindowMessages(queryClient, CHANNEL_ID); + assert.ok( + queryClient + .getQueryData(channelMessagesKey(CHANNEL_ID)) + .some((e) => e.id === reaction.id), + ); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [message.id]), + [], + ); +}); + test("failed hydration releases ids so the next render can retry", async () => { const messageId = hex("1"); const queryClient = makeQueryClientStub([event(messageId, 9)]); diff --git a/desktop/src/features/messages/lib/renderScopedReactions.ts b/desktop/src/features/messages/lib/renderScopedReactions.ts index 2c35cdf79e7..0f43ed3db74 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.ts +++ b/desktop/src/features/messages/lib/renderScopedReactions.ts @@ -1,6 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; -import { channelMessagesKey, sortMessages } from "./messageQueryKeys"; +import { channelWindowKey } from "./messageQueryKeys"; +import { + emptyChannelWindowStore, + mergeLiveChannelWindowEvent, + type ChannelWindowStore, +} from "./channelWindowStore"; +import { projectChannelWindowMessages } from "./projectChannelWindow"; import type { MainTimelineEntry } from "./threadPanel"; import type { TimelineMessage } from "../types"; import { relayClient } from "@/shared/api/relayClient"; @@ -125,10 +131,15 @@ export async function hydrateRenderScopedReactions(input: { return; } - input.queryClient.setQueryData( - channelMessagesKey(input.channelId), - (current = []) => sortMessages([...current, ...reactionEvents]), - ); + const windowKey = channelWindowKey(input.channelId); + let window = + input.queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + for (const event of reactionEvents) { + window = mergeLiveChannelWindowEvent(window, event, false); + } + input.queryClient.setQueryData(windowKey, window); + projectChannelWindowMessages(input.queryClient, input.channelId); } catch (error) { releaseRenderScopedReactionIds(input.channelId, messageIds); console.error( diff --git a/desktop/src/features/messages/lib/revalidateChannelWindow.test.mjs b/desktop/src/features/messages/lib/revalidateChannelWindow.test.mjs new file mode 100644 index 00000000000..e58be246798 --- /dev/null +++ b/desktop/src/features/messages/lib/revalidateChannelWindow.test.mjs @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { revalidateChannelWindow } from "./revalidateChannelWindow.ts"; +import { + appendOlderChannelWindow, + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "./channelWindowStore.ts"; +const event = (n) => ({ id: String(n), created_at: n }); +const cursor = (n) => ({ eventId: String(n), createdAt: n }); +const page = (start, ids, more = true) => ({ + startCursor: start, + rows: ids.map((n) => ({ event: event(n), thread: null })), + aux: [], + hasMore: more, + nextCursor: more ? cursor(ids.at(-1)) : null, +}); +const retained = () => + appendOlderChannelWindow( + replaceNewestChannelWindow(emptyChannelWindowStore(), page(null, [10, 9])), + page(cursor(9), [8, 7]), + ); + +test("unchanged head verifies one fresh join page before reusing deeper history", async () => { + const current = retained(); + const head = page(null, [11, 10, 9]); + const pages = await revalidateChannelWindow({ + publish: (pages) => pages, + head, + retained: current, + readCurrent: () => current, + fetchPage: async (start) => { + assert.deepEqual(start, cursor(9)); + return current.pages[1]; + }, + signal: new AbortController().signal, + }); + assert.deepEqual(pages, [head, current.pages[1]]); +}); + +test("moved head stages a replacement chain before publishing deep history", async () => { + const current = retained(); + const head = page(null, [12, 11]); + const fetched = []; + const pages = await revalidateChannelWindow({ + publish: (pages) => pages, + head, + retained: current, + readCurrent: () => current, + fetchPage: async (start) => { + fetched.push(start); + assert.equal( + current.pages.length, + 2, + "published view remains unchanged during requests", + ); + return start.createdAt === 11 ? page(start, [10, 9]) : current.pages[1]; + }, + signal: new AbortController().signal, + }); + assert.deepEqual(fetched, [cursor(11), cursor(9)]); + assert.deepEqual(pages, [head, page(cursor(11), [10, 9]), current.pages[1]]); +}); + +test("changed boundaries follow only fresh cursors until the old range is covered", async () => { + const current = retained(); + const head = page(null, [13, 12]); + const pages = await revalidateChannelWindow({ + publish: (pages) => pages, + head, + retained: current, + readCurrent: () => current, + fetchPage: async (start) => + page(start, [start.createdAt - 1, start.createdAt - 2]), + signal: new AbortController().signal, + }); + assert.deepEqual( + pages.map((p) => p.rows.map((r) => r.event.id)), + [ + ["13", "12"], + ["11", "10"], + ["9", "8"], + ["7", "6"], + ], + ); +}); + +test("failure, budget and cancellation preserve the old authoritative store", async () => { + const current = retained(); + const original = structuredClone(current); + const input = { + head: page(null, [100, 99]), + retained: current, + readCurrent: () => current, + signal: new AbortController().signal, + }; + await assert.rejects( + revalidateChannelWindow({ + publish: (pages) => pages, + ...input, + fetchPage: async () => { + throw Error("offline"); + }, + }), + /offline/, + ); + let requests = 0; + await assert.rejects( + revalidateChannelWindow({ + publish: (pages) => pages, + ...input, + fetchPage: async (start) => { + requests++; + return page(start, [start.createdAt - 1]); + }, + }), + /budget/, + ); + assert.equal(requests, 5); + const abort = new AbortController(); + abort.abort(); + await assert.rejects( + revalidateChannelWindow({ + publish: (pages) => pages, + ...input, + signal: abort.signal, + fetchPage: async () => { + throw Error("must not fetch"); + }, + }), + { name: "AbortError" }, + ); + assert.deepEqual(current, original); +}); + +test("concurrent paging during staged fetch extends the required reading depth before synchronous publication", async () => { + let current = retained(); + let published; + const pages = await revalidateChannelWindow({ + head: page(null, [12, 11]), + retained: current, + readCurrent: () => current, + signal: new AbortController().signal, + fetchPage: async (start) => { + if (start.createdAt === 11) { + current = appendOlderChannelWindow(current, page(cursor(7), [6, 5])); + return page(start, [10, 9]); + } + return current.pages[1]; + }, + publish: (pages) => { + published = pages; + return pages; + }, + }); + assert.equal(pages, published); + assert.deepEqual( + pages.map((p) => p.rows.map((r) => r.event.id)), + [ + ["12", "11"], + ["10", "9"], + ["8", "7"], + ["6", "5"], + ], + ); +}); + +test("even a reader in the first page retains their old boundary on head refresh", async () => { + const current = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [10, 9]), + ); + const pages = await revalidateChannelWindow({ + head: page(null, [12, 11]), + retained: current, + readCurrent: () => current, + signal: new AbortController().signal, + fetchPage: async (start) => page(start, [10, 9]), + publish: (pages) => pages, + }); + assert.deepEqual( + pages.map((p) => p.rows.map((r) => r.event.id)), + [ + ["12", "11"], + ["10", "9"], + ], + ); +}); + +// Relay-shaped source with composite ordering and candidate-based cursors. +// Compare the published sequence against server truth, not just old-row retention. +function model(rows, skipped = new Set()) { + const ordered = [...rows].sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + ); + return (start, limit = 50) => { + const eligible = ordered.filter( + (row) => + !start || + row.created_at < start.createdAt || + (row.created_at === start.createdAt && row.id > start.eventId), + ); + const candidates = eligible.slice(0, limit); + const last = candidates.at(-1); + const more = eligible.length > candidates.length; + return { + startCursor: start, + rows: candidates + .filter((row) => !skipped.has(row.id)) + .map((event) => ({ event, thread: null })), + aux: [], + hasMore: more, + nextCursor: more + ? { createdAt: last.created_at, eventId: last.id } + : null, + }; + }; +} +function deepStore(fetch, depth = 20) { + let current = replaceNewestChannelWindow( + emptyChannelWindowStore(), + fetch(null), + ); + for (let n = 1; n < depth; n++) + current = appendOlderChannelWindow( + current, + fetch(current.pages.at(-1).nextCursor), + ); + return current; +} +const sourceRows = () => + Array.from({ length: 2000 }, (_, index) => ({ + id: String(5000 - index).padStart(64, "0"), + created_at: 5000 - index, + })); +async function refreshModel(current, fetch) { + const requests = []; + const pages = await revalidateChannelWindow({ + head: fetch(null), + retained: current, + readCurrent: () => current, + fetchPage: async (cursor, limit) => { + requests.push(limit); + return fetch(cursor, limit); + }, + signal: new AbortController().signal, + publish: (pages) => pages, + }); + return { pages, requests }; +} +function assertContiguous(pages, fetch) { + const actual = pages.flatMap((page) => page.rows.map((row) => row.event.id)); + const expected = fetch(null, Infinity) + .rows.slice(0, actual.length) + .map((row) => row.event.id); + assert.deepEqual(actual, expected); +} +for (const added of [1, 7, 50, 137, 201, 250, 400]) { + test(`${added} head arrivals join without re-fetching twenty retained pages`, async () => { + const rows = sourceRows(); + const current = deepStore(model(rows)); + const fetch = model([ + ...rows, + ...Array.from({ length: added }, (_, n) => ({ + id: `new-${n}`, + created_at: 6000 + n, + })), + ]); + const { pages, requests } = await refreshModel(current, fetch); + assertContiguous(pages, fetch); + assert.ok( + pages + .flatMap((p) => p.rows) + .some((r) => r.event.id === current.pages.at(-1).rows.at(-1).event.id), + ); + assert.ok( + requests.length <= Math.ceil(added / 50) + 1, + JSON.stringify(requests), + ); + if (added === 1) assert.deepEqual(requests, [1, 50]); + }); +} +test("fresh join verification includes a new dense-second row immediately after the old boundary", async () => { + const rows = sourceRows(); + const current = deepStore(model(rows)); + const boundary = current.pages[0].rows.at(-1).event; + const twin = { id: "f".repeat(64), created_at: boundary.created_at }; + const fetch = model([...rows, { id: "new", created_at: 6000 }, twin]); + const { pages } = await refreshModel(current, fetch); + assertContiguous(pages, fetch); + assert.ok(pages.flatMap((p) => p.rows).some((r) => r.event.id === twin.id)); +}); +for (const skip of [false, true]) { + test(`${skip ? "skipped scan candidate" : "deleted boundary"} cannot invent a join or lose retained history`, async () => { + const rows = sourceRows(); + const boundary = rows[49].id; + const skipped = new Set(skip ? [boundary] : []); + const current = deepStore(model(rows, skipped)); + const fetch = model( + [ + ...rows.filter((row) => skip || row.id !== boundary), + { id: "new", created_at: 6000 }, + ], + skipped, + ); + const { pages, requests } = await refreshModel(current, fetch); + assertContiguous(pages, fetch); + assert.ok(requests.length < 5, JSON.stringify(requests)); + }); +} diff --git a/desktop/src/features/messages/lib/revalidateChannelWindow.ts b/desktop/src/features/messages/lib/revalidateChannelWindow.ts new file mode 100644 index 00000000000..74b35abd2a8 --- /dev/null +++ b/desktop/src/features/messages/lib/revalidateChannelWindow.ts @@ -0,0 +1,100 @@ +import { + appendOlderChannelWindow, + compareRelayOrder, + emptyChannelWindowStore, + replaceNewestChannelWindow, + type ChannelWindowCursor, + type ChannelWindowPage, + type ChannelWindowStore, +} from "./channelWindowStore"; + +/** Stage a refreshed head without evicting the reading window. Reuse history + * only at an exact cursor join; otherwise follow server cursors until the new + * chain covers the old window. Failure leaves the published store untouched. + */ +export async function revalidateChannelWindow({ + head, + retained, + readCurrent, + fetchPage, + signal, + publish, +}: { + head: ChannelWindowPage; + retained: ChannelWindowStore; + readCurrent: () => ChannelWindowStore; + fetchPage: ( + cursor: ChannelWindowCursor, + limitRows: number, + ) => Promise; + signal: AbortSignal; + /** Called synchronously after the final readCurrent check; no await gap may + * let a concurrent older page extend the reading window before publication. */ + publish: (pages: ChannelWindowPage[]) => T; +}): Promise { + let staged = replaceNewestChannelWindow(emptyChannelWindowStore(), head); + // Bound reconnect work, including a reader extending history concurrently. + // A busy channel can retry later; silently truncating the reader is not an + // acceptable fallback when the new head is too far away. + const budget = retained.pages.length + 3; + let verifiedJoin = false; + for (let requests = 0; ; requests++) { + signal.throwIfAborted(); + const current = readCurrent(); + const oldTail = current.pages.at(-1); + const tail = staged.pages.at(-1); + if (!tail) throw new Error("History refresh has no staged head."); + if (!tail.hasMore || !oldTail) return publish(staged.pages); + const cursor = tail.nextCursor; + if (!cursor) throw new Error("History refresh is missing its next cursor."); + const join = current.pages.findIndex( + (page) => + page.startCursor?.createdAt === cursor.createdAt && + page.startCursor.eventId === cursor.eventId, + ); + if (join >= 0 && verifiedJoin) { + for (const page of current.pages.slice(join)) + staged = appendOlderChannelWindow(staged, page); + return publish(staged.pages); + } + const oldest = oldTail.rows.at(-1)?.event; + if ( + oldest && + compareRelayOrder( + { created_at: cursor.createdAt, id: cursor.eventId } as typeof oldest, + oldest, + ) >= 0 + ) + return publish(staged.pages); + if (requests >= budget) + throw new Error( + "History refresh exceeded its reading-window budget; retaining the current timeline.", + ); + let limitRows = 50; + // Ask the relay for a short bridge to an old page boundary instead of + // re-fetching all retained history when a one-row head change misaligns + // every 50-row page. This is only a request-size hint: echoed bounds remain + // authoritative, including when rows were deleted or reconstruction skips. + for (const page of current.pages) { + const index = page.rows.findIndex( + (row) => + row.event.id === cursor.eventId && + row.event.created_at === cursor.createdAt, + ); + if (index >= 0 && index + 1 < page.rows.length) { + limitRows = page.rows.length - index - 1; + break; + } + } + // Re-read one page AFTER the first exact join before adopting deeper + // immutable history. A new dense-second row can sort immediately after the + // old boundary; the short bridge ending at that boundary cannot see it. + // Only the first join is verified; deeper boundaries are trusted under + // NIP-CW's immutable-history contract, not arbitrary backfill repair. + if (join >= 0) verifiedJoin = true; + staged = appendOlderChannelWindow( + staged, + await fetchPage(cursor, limitRows), + ); + } +} diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a8afb823ecb..69f0a51ae29 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -670,7 +670,7 @@ test("timeline-intro-surface: channel intro waits for oldest-history boundary", ); }); -test("timeline-intro-surface: direct-message intro wins over channel intro", () => { +test("timeline-intro-surface: direct-message intro waits for committed exhaustion", () => { assert.equal( selectTimelineIntroSurface({ hasChannelIntro: true, @@ -678,6 +678,18 @@ test("timeline-intro-surface: direct-message intro wins over channel intro", () hasReachedChannelStart: false, isSkeletonVisible: false, }), + null, + ); +}); + +test("timeline-intro-surface: direct-message intro wins at the channel beginning", () => { + assert.equal( + selectTimelineIntroSurface({ + hasChannelIntro: true, + hasDirectMessageIntro: true, + hasReachedChannelStart: true, + isSkeletonVisible: false, + }), "direct-message-intro", ); }); diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 4e23fb22453..3c959b7ab30 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -362,7 +362,7 @@ export function selectTimelineIntroSurface({ if (isSkeletonVisible) { return null; } - if (hasDirectMessageIntro) { + if (hasDirectMessageIntro && hasReachedChannelStart) { return "direct-message-intro"; } if (hasChannelIntro && hasReachedChannelStart) { diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx new file mode 100644 index 00000000000..7cd304c3546 --- /dev/null +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from "react"; +import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; +import { channelMessagesKey, channelWindowKey } from "../lib/messageQueryKeys"; +import { + emptyChannelWindowStore, + type ChannelWindowStore, +} from "../lib/channelWindowStore"; +import { refreshChannelWindowMessages } from "../lib/projectChannelWindow"; +import { Button } from "@/shared/ui/button"; + +/** Recovery is explicit: keep reading, retry preserving history, or load latest. */ +export function HistoryRefreshNotice({ + channelId, + onLoadLatest, +}: { + channelId?: string | null; + onLoadLatest: () => void; +}) { + const client = useQueryClient(); + const { data: window } = useQuery({ + queryKey: channelWindowKey(channelId ?? "none"), + enabled: false, + staleTime: Infinity, + }); + const isRefreshing = + useIsFetching({ + queryKey: channelMessagesKey(channelId ?? "none"), + exact: true, + }) > 0; + const scopeRef = useRef({ channelId, active: true }); + if (scopeRef.current.channelId !== channelId) { + scopeRef.current.active = false; + scopeRef.current = { channelId, active: true }; + } + const scope = scopeRef.current; + useEffect(() => { + scope.active = true; + return () => { + scope.active = false; + }; + }, [scope]); + if (!channelId || !window?.refreshError) return null; + const retry = async (latest: boolean) => { + client.setQueryData( + channelWindowKey(channelId), + (current) => ({ + ...(current ?? emptyChannelWindowStore()), + refreshLatestOnly: latest, + }), + ); + await refreshChannelWindowMessages(client, channelId); + const refreshed = client.getQueryData( + channelWindowKey(channelId), + ); + // Failed recovery leaves the reader where they were. Do not wait for a + // new tail id: an unchanged head still needs to honor explicit Load latest. + if ( + latest && + scope.active && + refreshed?.pages.length && + !refreshed.refreshError + ) + onLoadLatest(); + }; + return ( +
+ {window.refreshError} + + +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fa8bb4e9f6d..c0edf8beb63 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -24,12 +24,16 @@ import { TimelineMessageList } from "./TimelineMessageList"; import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; -import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages"; +import { + useAdmittedTimelineSnapshot, + type TimelineSnapshot, +} from "./useAdmittedTimelineSnapshot"; +import { HistoryRefreshNotice } from "./HistoryRefreshNotice"; +import { useHistoryPagination } from "./useHistoryPagination"; import { DirectMessageIntroAvatarStack, type DirectMessageIntroParticipant, } from "./DirectMessageIntroAvatarStack"; -import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages"; export type MessageTimelineHandle = { scrollToBottomOnNextUpdate: () => void; @@ -58,7 +62,9 @@ type MessageTimelineProps = { emptyTitle?: string; emptyDescription?: string; currentPubkey?: string; - fetchOlder?: () => Promise; + fetchOlder?: () => Promise; + /** Authoritative history publication paired with the message snapshot. */ + historyRevision?: number; hasOlderMessages?: boolean; /** * True when the loaded window provably starts at the channel's beginning @@ -130,28 +136,6 @@ type MessageTimelineProps = { * message list. Must be module-level so its identity never changes. */ const EMPTY_MESSAGES: TimelineMessage[] = []; -type TimelineSnapshot = { - channelId: string | null; - messages: TimelineMessage[]; - /** - * History-exhaustion proof captured with the SAME rows it was derived from. - * The oldest-day divider may only exist when this is true, and rows and - * proof must travel every transport stage (deferral, buffering, settle - * gating) as one value: delivering a fresh proof on the urgent render path - * while the rows ride the deferred path lets an intermediate commit mint a - * divider against the previous, partially-loaded oldest day — which breaks - * Virtua's exact-suffix shift admission when the withheld same-day rows - * finally land (the pass-1 tear, ledgered 2026-07-11). - */ - historyExhausted: boolean; -}; - -const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = { - channelId: null, - messages: EMPTY_MESSAGES, - historyExhausted: false, -}; - const MessageTimelineBase = React.forwardRef< MessageTimelineHandle, MessageTimelineProps @@ -177,6 +161,7 @@ const MessageTimelineBase = React.forwardRef< pinnedIntro, hasOlderMessages = true, historyExhausted = false, + historyRevision = 0, isFetchingOlder = false, followThreadById, huddleMemberPubkeys, @@ -247,13 +232,44 @@ const MessageTimelineBase = React.forwardRef< // route change can paint the previous channel's deferred rows for a frame even // though the sidebar/header already moved to the new channel. const liveSnapshot = React.useMemo( - () => ({ channelId: channelId ?? null, messages, historyExhausted }), - [channelId, historyExhausted, messages], - ); - const deferredSnapshot = React.useDeferredValue( - liveSnapshot, - EMPTY_TIMELINE_SNAPSHOT, + () => ({ + channelId: channelId ?? null, + messages, + historyExhausted, + historyRevision, + firstUnreadMessageId, + threadSummaries, + mainEntries, + }), + [ + channelId, + historyExhausted, + historyRevision, + messages, + firstUnreadMessageId, + threadSummaries, + mainEntries, + ], ); + const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] = + React.useState(true); + const { + deferred: deferredSnapshot, + admitted, + pendingCount, + } = useAdmittedTimelineSnapshot({ + snapshot: liveSnapshot, + isAtBottom: + isSemanticallyAtBottom || + targetMessageId !== null || + searchActiveMessageId !== null, + scrollElementRef: activeScrollContainerRef, + }); + const { + messages: renderedMessages, + meta: renderedSnapshot, + isHoldingPrepend, + } = admitted; const deferredMessages = deferredSnapshot.messages; const imagePreloadStateRef = React.useRef({ activeImages: new Set(), @@ -303,45 +319,27 @@ const MessageTimelineBase = React.forwardRef< liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; - const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] = - React.useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes React.useEffect(() => { setIsSemanticallyAtBottom(true); }, [channelId]); - // Zulip-style data semantics: once the reader leaves the bottom, keep the - // virtualizer's logical tail frozen. Live arrivals accumulate behind the - // "new messages" affordance instead of changing Virtua's item model under - // the reading position. Prepends still flow through immediately and Virtua's - // `shift` transaction preserves the stable keyed row. - const bufferedTimeline = useBufferedTimelineMessages({ + const historyPagination = useHistoryPagination({ channelId, - isAtBottom: - isSemanticallyAtBottom || - targetMessageId !== null || - searchActiveMessageId !== null, - messages: deferredMessages, - }); - // Hold older-page render commits until the scroller is at rest: WKWebView - // can drop scrollTop compensation writes during live trackpad momentum. - // Full rationale in useSettleGatedPrependMessages. - // - // The history-exhaustion proof rides through this gate as snapshot metadata - // (`meta`), so while a prepend is withheld the rendered rows keep the proof - // they were projected with. The buffering stage above cannot split the pair: - // it only freezes the TAIL (live arrivals) and passes history prepends - // through unchanged, so the oldest rows the proof speaks about are exactly - // the deferred snapshot's oldest rows. - const { - messages: renderedMessages, - meta: renderedHistoryExhausted, - isHoldingPrepend, - } = useSettleGatedPrependMessages({ - channelId, - messages: bufferedTimeline.messages, - meta: deferredSnapshot.historyExhausted, + fetchOlder, + canLoad: + !searchActiveMessageId && + !targetMessageId && + !isFetchingOlder && + !isHoldingPrepend && + !showTimelineSkeleton && + hasOlderMessages && + !isRenderedTimelineBehindHistoryPrepend(renderedMessages, messages), + renderedRevision: renderedSnapshot.historyRevision, + renderedChannelId: renderedSnapshot.channelId, + fillViewport: !isLoading && !isDeferredSnapshotStale, scrollElementRef: activeScrollContainerRef, }); + const cancelHistoryPagination = historyPagination.cancel; const { highlightedMessageId, @@ -428,10 +426,7 @@ const MessageTimelineBase = React.forwardRef< const timelineIntroSurface = selectTimelineIntroSurface({ hasChannelIntro: channelIntro !== null && directMessageIntro === null, hasDirectMessageIntro: directMessageIntro !== null, - hasReachedChannelStart: - !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && - !isHoldingPrepend && - (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), + hasReachedChannelStart: renderedSnapshot.historyExhausted, isSkeletonVisible: showTimelineSkeleton, }); const showDirectMessageIntro = @@ -456,9 +451,10 @@ const MessageTimelineBase = React.forwardRef< // The user's own send is the deliberate Zulip exception: release buffered // output before arming the next-append bottom pin so the sent row can enter // Virtua's model and become the new physical floor. + cancelHistoryPagination(); setIsSemanticallyAtBottom(true); scrollToBottomOnNextUpdate(); - }, [scrollToBottomOnNextUpdate]); + }, [cancelHistoryPagination, scrollToBottomOnNextUpdate]); React.useImperativeHandle( ref, @@ -466,15 +462,21 @@ const MessageTimelineBase = React.forwardRef< scrollToBottomOnNextUpdate: prepareForOwnMessage, settleAtBottom: () => { if (!timelineVirtualizerApi) return false; + cancelHistoryPagination(); scrollToBottom("auto"); return true; }, }), - [prepareForOwnMessage, scrollToBottom, timelineVirtualizerApi], + [ + cancelHistoryPagination, + prepareForOwnMessage, + scrollToBottom, + timelineVirtualizerApi, + ], ); - // Jump-to-message is purely DOM-based now: all loaded rows are mounted, so - // `scrollToMessage` always finds the target row. No virtualizer convergence. + // The virtualizer realizes an offscreen target before the DOM-based + // centering/highlight path retries on its rendered-range notification. const jumpToMessage = React.useCallback( (messageId: string, options?: { behavior?: ScrollBehavior }) => { return scrollToMessage(messageId, { highlight: true, ...options }); @@ -566,33 +568,6 @@ const MessageTimelineBase = React.forwardRef< virtualizerRenderVersion, ]); - const loadOlderViaVirtualizer = React.useCallback((): boolean => { - // Indexed find navigation can legitimately land near the current history - // boundary. Do not mistake that programmatic jump for scrollback intent and - // prepend underneath the active match. - // A settle-gate hold means the reader is still parked at the OLD - // boundary — don't stack more page fetches behind the held commit. - if ( - searchActiveMessageId || - !fetchOlder || - isFetchingOlder || - isHoldingPrepend || - showTimelineSkeleton || - !hasOlderMessages - ) { - return false; - } - void fetchOlder(); - return true; - }, [ - fetchOlder, - hasOlderMessages, - isFetchingOlder, - isHoldingPrepend, - searchActiveMessageId, - showTimelineSkeleton, - ]); - useLoadOlderOnScroll({ fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder, hasOlderMessages, @@ -647,7 +622,7 @@ const MessageTimelineBase = React.forwardRef< channelName={channelName} channelType={channelType} currentPubkey={currentPubkey} - firstUnreadMessageId={firstUnreadMessageId} + firstUnreadMessageId={renderedSnapshot.firstUnreadMessageId} followThreadById={followThreadById} highlightedMessageId={highlightedMessageId} huddleMemberPubkeys={huddleMemberPubkeys} @@ -657,13 +632,17 @@ const MessageTimelineBase = React.forwardRef< entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} messageFooters={messageFooters} - mainEntries={renderedMessages === messages ? mainEntries : undefined} + mainEntries={ + renderedMessages === renderedSnapshot.messages + ? renderedSnapshot.mainEntries + : undefined + } leadingContent={virtualizedLeadingContent} - historyExhausted={renderedHistoryExhausted} + historyExhausted={renderedSnapshot.historyExhausted} hideDayDividers={hideDayDividers} alwaysShowMessageIdentity={alwaysShowMessageIdentity} hideAgentAccessBadges={hideAgentAccessBadges} - threadSummaries={threadSummaries} + threadSummaries={renderedSnapshot.threadSummaries} messages={renderedMessages} onDelete={onDelete} onEdit={onEdit} @@ -673,7 +652,7 @@ const MessageTimelineBase = React.forwardRef< onOpenThread={onOpenThread} isSendingVideoReviewComment={isSendingVideoReviewComment} onSendVideoReviewComment={onSendVideoReviewComment} - onStartReached={loadOlderViaVirtualizer} + onStartReached={historyPagination.start} onToggleReaction={onToggleReaction} onVirtualizerApiChange={setTimelineVirtualizerApi} onVirtualizerRangeChanged={handleVirtualizerRangeChanged} @@ -712,7 +691,8 @@ const MessageTimelineBase = React.forwardRef< {/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame later (deferred snapshot / settle-gate hold) — keep the spinner up until the page actually renders. */} - {isFetchingOlder || + {historyPagination.isPending || + isFetchingOlder || isHoldingPrepend || isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
+
+ { + cancelHistoryPagination(); + setIsSemanticallyAtBottom(true); + window.requestAnimationFrame(() => scrollToBottom("auto")); + }} + /> +
{!isAtBottom ? (
0 - ? unreadCountLabel(bufferedTimeline.pendingCount) + pendingCount > 0 + ? unreadCountLabel(pendingCount) : newMessageCount > 0 ? unreadCountLabel(newMessageCount) : "Jump to latest" } onClick={() => { + cancelHistoryPagination(); setIsSemanticallyAtBottom(true); window.requestAnimationFrame(() => scrollToBottom("auto")); }} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d7ef78ea04b..4e17a421936 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -31,6 +31,7 @@ import { MessageRowItem, SystemRow } from "./TimelineMessageRow"; import { TimelineRowShell } from "./TimelineRowShell"; import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; +import { useHistoryBoundaryIntent } from "./useHistoryBoundaryIntent"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle"; @@ -441,12 +442,8 @@ function VirtualizedTimelineRows({ const estimateCallCountRef = React.useRef(0); const estimateItemSize = React.useCallback( (item: VirtualizedTimelineItem) => { - estimateCallCountRef.current += 1; - const scroller = hostRef.current?.firstElementChild; - if (scroller instanceof HTMLDivElement) { - scroller.dataset.virtuaEstimateCallCount = String( - estimateCallCountRef.current, - ); + if (import.meta.env?.MODE === "e2e") { + estimateCallCountRef.current += 1; } return estimateVirtualizedTimelineItemHeight(item); }, @@ -462,6 +459,16 @@ function VirtualizedTimelineRows({ ), [dayGroups, hideDayDividers, historyExhausted, leadingContent], ); + // Instrument once per commit, never a DOM attribute write per estimated row. + React.useLayoutEffect(() => { + if (import.meta.env?.MODE !== "e2e") return; + const scroller = hostRef.current?.firstElementChild; + if (scroller instanceof HTMLDivElement) { + scroller.dataset.virtuaEstimateCallCount = String( + estimateCallCountRef.current, + ); + } + }); const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); const dayDividerItems = React.useMemo( () => @@ -487,6 +494,12 @@ function VirtualizedTimelineRows({ cancelBottomSettle, ); + const tryLoadOlder = useHistoryBoundaryIntent( + hostRef, + onStartReached, + armUpwardMomentum, + ); + const updatePinnedDayLabel = React.useCallback( (offset: number) => { const list = listRef.current; @@ -720,13 +733,12 @@ function VirtualizedTimelineRows({ updatePinnedDayLabel(offset); if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. - armUpwardMomentum(onStartReached?.() ?? false); + tryLoadOlder(); } }, [ - armUpwardMomentum, + tryLoadOlder, onAtBottomStateChange, - onStartReached, onVirtualizerRangeChanged, updatePinnedDayLabel, ], @@ -739,6 +751,7 @@ function VirtualizedTimelineRows({ ref={listRef} className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-[var(--channel-top-chrome-height,4.5rem)]" data={items} + itemKey={virtualizedItemKey} item={VirtualizedTimelineItemShell} itemSize={estimateItemSize} bufferSize={offscreenBufferSize} diff --git a/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs new file mode 100644 index 00000000000..e51db2f2cbf --- /dev/null +++ b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs @@ -0,0 +1,828 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel.ts"; +import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages.ts"; + +async function setup(t) { + const dom = new JSDOM( + "
", + ); + const frames = new Map(); + let frameId = 0; + let now = 0; + const globals = { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + HTMLDivElement: dom.window.HTMLDivElement, + Node: dom.window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame(fn) { + frames.set(++frameId, fn); + return frameId; + }, + cancelAnimationFrame(id) { + frames.delete(id); + }, + }; + const saved = Object.fromEntries( + Object.keys(globals).map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key), + ]), + ); + for (const [key, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + } + const originalNow = Object.getOwnPropertyDescriptor(performance, "now"); + Object.defineProperty(performance, "now", { + value: () => now, + configurable: true, + }); + const host = document.getElementById("host"); + const scroller = host.firstElementChild; + Object.defineProperties(scroller, { + scrollHeight: { value: 3000 }, + clientHeight: { value: 600 }, + }); + const root = createRoot(document.getElementById("root")); + t.after(async () => { + await act(async () => { + root.unmount(); + // Query's notifyManager batches mutation-observer callbacks on a timer. + // Await delivery while JSDOM still exists, even after the cache mutation + // promise has settled; those callbacks can still enter React DOM. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + dom.window.close(); + if (originalNow) Object.defineProperty(performance, "now", originalNow); + else delete performance.now; + for (const [key, descriptor] of Object.entries(saved)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + return { + root, + scroller, + hostRef: { current: host }, + async frame(ms = 16) { + now += ms; + const pending = [...frames.values()]; + frames.clear(); + await act(async () => { + for (const fn of pending) fn(now); + }); + }, + wheel(deltaY = -10) { + const event = new dom.window.WheelEvent("wheel", { + deltaY, + cancelable: true, + }); + scroller.dispatchEvent(event); + return event; + }, + }; +} + +test("last-tick paging cannot swallow a new wheel gesture after a pause", async (t) => { + const env = await setup(t); + let paging; + const onWheel = () => {}; + function Harness() { + paging = useUpwardPaginationWheel(env.hostRef, onWheel); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + env.wheel(); + paging.arm(true); + await env.frame(1000); + assert.equal(env.wheel().defaultPrevented, false); +}); + +test("fresh input past the old hold deadline never admits a prepend", async (t) => { + const env = await setup(t); + const scrollElementRef = { current: env.scroller }; + let output; + function Harness({ messages }) { + output = useSettleGatedPrependMessages({ + channelId: "a", + messages, + meta: messages[0].id, + scrollElementRef, + }); + return null; + } + const oldRows = [{ id: "a" }, { id: "b" }]; + const nextRows = [{ id: "older" }, ...oldRows]; + await act(async () => + env.root.render(React.createElement(Harness, { messages: oldRows })), + ); + await act(async () => + env.root.render(React.createElement(Harness, { messages: nextRows })), + ); + assert.equal(output.isHoldingPrepend, true); + for (let i = 0; i < 85; i++) { + env.wheel(); + await env.frame(50); + } + assert.equal( + output.isHoldingPrepend, + true, + "4 seconds is not proof that a new gesture has settled", + ); + assert.deepEqual(output.messages, oldRows); + for (let i = 0; i < 8; i++) await env.frame(); + assert.equal(output.isHoldingPrepend, false); + assert.deepEqual(output.messages, nextRows); + assert.equal(output.meta, "older"); +}); + +// Exercise the production transport and admission hooks, not a copied selector. +const { useAdmittedTimelineSnapshot } = await import( + "./useAdmittedTimelineSnapshot.ts" +); +const { useHistoryPagination } = await import("./useHistoryPagination.ts"); + +test("page reservation spans fast fetch, React deferral, settle hold and visual acknowledgement", async (t) => { + const env = await setup(t); + const scrollElementRef = { current: env.scroller }; + let publish; + let pager; + let rows; + let requests = 0; + let probeNextUrgentCommit = false; + const admissionDuringGap = []; + const oldRows = [{ id: "a" }, { id: "b" }]; + const olderRows = [{ id: "older" }, ...oldRows]; + const initial = { + channelId: "a", + messages: oldRows, + historyExhausted: false, + historyRevision: 1, + firstUnreadMessageId: "a", + }; + function Harness() { + const [snapshot, setSnapshot] = React.useState(initial); + publish = setSnapshot; + const { admitted } = useAdmittedTimelineSnapshot({ + snapshot, + isAtBottom: snapshot.historyRevision === 1, + scrollElementRef, + }); + rows = admitted; + pager = useHistoryPagination({ + channelId: snapshot.channelId, + canLoad: true, + renderedRevision: admitted.meta.historyRevision, + scrollElementRef, + fetchOlder: async () => { + requests++; + return 2; + }, + }); + React.useLayoutEffect(() => { + if ( + probeNextUrgentCommit && + snapshot.historyRevision === 2 && + admitted.meta.historyRevision === 1 + ) { + probeNextUrgentCommit = false; + admissionDuringGap.push(pager.start()); + } + }); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + await act(async () => { + assert.equal(pager.start(), true); + assert.equal( + pager.start(), + false, + "sync second callback cannot reserve a second request", + ); + }); + assert.equal( + pager.isPending, + true, + "network receipt alone does not release reservation", + ); + probeNextUrgentCommit = true; + await act(async () => + publish({ + ...initial, + messages: olderRows, + historyRevision: 2, + historyExhausted: true, + firstUnreadMessageId: null, + }), + ); + assert.deepEqual( + admissionDuringGap, + [false], + "production deferral urgent commit stays locked", + ); + assert.equal(rows.isHoldingPrepend, true); + assert.equal( + rows.meta.firstUnreadMessageId, + "a", + "marker belongs to held rows", + ); + for (let i = 0; i < 8; i++) await env.frame(); + assert.deepEqual(rows.messages, olderRows); + assert.equal(rows.meta.firstUnreadMessageId, null); + assert.equal( + pager.isPending, + true, + "DOM commit still awaits stable measurements", + ); + for (let i = 0; i < 5; i++) await env.frame(); + assert.equal(pager.isPending, false); + assert.equal(requests, 1); +}); + +test("navigation retires old request receipt without blocking the new channel", async (t) => { + const env = await setup(t); + const scrollElementRef = { current: env.scroller }; + let pager; + const resolves = []; + const fetchOlder = () => new Promise((resolve) => resolves.push(resolve)); + function Harness({ channelId }) { + pager = useHistoryPagination({ + channelId, + fetchOlder, + canLoad: true, + renderedRevision: 1, + scrollElementRef, + }); + return null; + } + await act(async () => + env.root.render(React.createElement(Harness, { channelId: "a" })), + ); + await act(async () => assert.equal(pager.start(), true)); + await act(async () => + env.root.render(React.createElement(Harness, { channelId: "b" })), + ); + assert.equal(pager.isPending, false); + await act(async () => assert.equal(pager.start(), true)); + await act(async () => resolves[0](50)); + assert.equal( + pager.isPending, + true, + "old channel cannot unlock the active request", + ); + await act(async () => resolves[1](undefined)); + assert.equal( + pager.isPending, + false, + "no-op receipt releases the active request", + ); +}); + +const { useHistoryBoundaryIntent } = await import( + "./useHistoryBoundaryIntent.ts" +); + +test("only fresh reader intent pages once; layout scrolls cannot create or reuse a gesture", async (t) => { + const env = await setup(t); + let starts = 0; + let canStart = true; + let tryStart; + function Harness() { + tryStart = useHistoryBoundaryIntent( + env.hostRef, + () => { + if (!canStart) return false; + starts++; + return true; + }, + () => {}, + ); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + tryStart(); + assert.equal(starts, 0, "programmatic offset alone is not input"); + env.wheel(); + await env.frame(); + assert.equal(starts, 1, "upward wheel at zero still starts a page"); + for (let i = 0; i < 30; i++) { + env.wheel(); + await env.frame(40); + tryStart(); + } + assert.equal( + starts, + 1, + "a long continuous gesture cannot cascade even after receipt settles", + ); + await env.frame(200); + env.wheel(); + await env.frame(); + assert.equal(starts, 2, "new gesture can load next page"); + canStart = false; + await env.frame(200); + env.wheel(); + await env.frame(); + canStart = true; + await env.frame(200); + tryStart(); + assert.equal( + starts, + 2, + "rejected input cannot be reused by later layout motion", + ); + const key = new window.KeyboardEvent("keydown", { key: "PageUp" }); + env.scroller.dispatchEvent(key); + await env.frame(); + assert.equal(starts, 3, "keyboard history navigation supplies intent"); + await env.frame(200); + env.scroller.dispatchEvent( + new window.WheelEvent("wheel", { deltaY: -10, ctrlKey: true }), + ); + await env.frame(); + assert.equal(starts, 3, "zoom cannot page"); +}); + +test("returning to a channel cannot resurrect its retired transaction indicator", async (t) => { + const env = await setup(t); + const scrollElementRef = { current: env.scroller }; + let pager; + let resolve; + function Harness({ channelId }) { + pager = useHistoryPagination({ + channelId, + fetchOlder: () => + new Promise((done) => { + resolve = done; + }), + canLoad: true, + renderedRevision: 1, + scrollElementRef, + }); + return null; + } + await act(async () => + env.root.render(React.createElement(Harness, { channelId: "a" })), + ); + await act(async () => pager.start()); + await act(async () => + env.root.render(React.createElement(Harness, { channelId: "b" })), + ); + await act(async () => + env.root.render(React.createElement(Harness, { channelId: "a" })), + ); + assert.equal(pager.isPending, false); + await act(async () => resolve(2)); + assert.equal(pager.isPending, false); +}); + +// Real production projection hook, with intentionally separated cache/store +// notifications: the receipt, rows, summaries and exhaustion must still agree. +const { useHuddleChannelMessages } = await import( + "../../channels/ui/useHuddleChannelMessages.ts" +); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { + emptyChannelWindowStore, + replaceNewestChannelWindow, + appendOlderChannelWindow, +} = await import("../lib/channelWindowStore.ts"); +test("production channel projection pairs a window receipt with its rows and structural metadata", async (t) => { + const env = await setup(t); + const client = new QueryClient(); + t.after(() => client.clear()); + const event = (id, created_at) => ({ + id, + created_at, + kind: 9, + pubkey: "p", + content: id, + tags: [["h", "a"]], + sig: "", + }); + const headRow = event("newer", 2); + const oldRow = event("older", 1); + const summary = { + replyCount: 2, + descendantCount: 2, + participantPubkeys: [], + lastReplyAt: 1, + }; + const cursor = { createdAt: 2, eventId: "newer" }; + const head = replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: headRow, thread: null }], + aux: [], + nextCursor: cursor, + hasMore: true, + }); + const next = appendOlderChannelWindow(head, { + startCursor: cursor, + rows: [{ event: oldRow, thread: summary }], + aux: [], + nextCursor: null, + hasMore: false, + }); + let output; + function Harness({ windowStore, messages }) { + output = useHuddleChannelMessages({ + activeChannel: { id: "a", channelType: "stream" }, + isHuddleTranscript: false, + windowStore, + messages, + targetMessageEvents: [], + }); + return null; + } + const render = async (windowStore, messages) => + act(async () => + env.root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Harness, { windowStore, messages }), + ), + ), + ); + await render(head, [headRow]); + await render(next, [headRow]); // new window, old flattened-cache observer + assert.deepEqual( + output.resolvedMessages.map((row) => row.id), + ["older", "newer"], + ); + assert.equal(output.historyRevision, next.revision); + assert.equal(output.historyExhausted, true); + assert.equal(output.threadSummaries.get("older"), summary); + await render(head, [oldRow, headRow]); // inverse observer order + assert.deepEqual( + output.resolvedMessages.map((row) => row.id), + ["newer"], + ); + assert.equal(output.historyRevision, head.revision); + assert.equal(output.historyExhausted, false); + assert.equal(output.threadSummaries.has("older"), false); +}); + +test("explicit cancellation and failed/no-op requests release only their own reservation", async (t) => { + const env = await setup(t); + const scrollElementRef = { current: env.scroller }; + const pending = []; + const errors = []; + t.mock.method(console, "error", (...args) => errors.push(args)); + let pager; + function Harness({ canLoad = true, renderedRevision = 1 }) { + pager = useHistoryPagination({ + channelId: "a", + fetchOlder: () => + new Promise((resolve, reject) => pending.push({ resolve, reject })), + canLoad, + renderedRevision, + scrollElementRef, + }); + return null; + } + await act(async () => + env.root.render(React.createElement(Harness, { canLoad: false })), + ); + assert.equal( + pager.start(), + false, + "exhausted/navigation states do not request", + ); + await act(async () => env.root.render(React.createElement(Harness))); + await act(async () => pager.start()); + await act(async () => pager.cancel()); + assert.equal(pager.isPending, false); + await act(async () => pager.start()); + await act(async () => pending[0].resolve(30)); + assert.equal( + pager.isPending, + true, + "cancelled receipt cannot release a newer request", + ); + await act(async () => pending[1].reject(Error("offline"))); + assert.equal(pager.isPending, false); + assert.equal(errors.length, 1); + await act(async () => pager.start()); + await act(async () => pending[2].resolve(undefined)); + assert.equal(pager.isPending, false); + await act(async () => pager.start()); + await act(async () => pending[3].resolve(2)); + for (let i = 0; i < 8; i++) await env.frame(); + assert.equal( + pager.isPending, + true, + "geometry alone cannot acknowledge an unrendered receipt", + ); + await act(async () => + env.root.render(React.createElement(Harness, { renderedRevision: 2 })), + ); + for (let i = 0; i < 3; i++) await env.frame(); + env.scroller.scrollTop += 20; + await env.frame(); + assert.equal( + pager.isPending, + true, + "late layout resets the stable-frame count", + ); + for (let i = 0; i < 3; i++) await env.frame(); + assert.equal(pager.isPending, false); +}); + +test("keyboard input ignores editing/modifiers and consumes only one held key", async (t) => { + const env = await setup(t); + let starts = 0; + function Harness() { + useHistoryBoundaryIntent( + env.hostRef, + () => { + starts++; + return true; + }, + () => {}, + ); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + const key = async (target, options) => { + target.dispatchEvent( + new window.KeyboardEvent("keydown", { bubbles: true, ...options }), + ); + await env.frame(); + }; + const editor = document.createElement("div"); + editor.setAttribute("contenteditable", "true"); + const child = document.createElement("span"); + editor.append(child); + env.scroller.append(editor); + await key(child, { key: "Home" }); + await key(env.scroller, { key: "Home", metaKey: true }); + await key(env.scroller, { key: "PageDown" }); + assert.equal(starts, 0); + await key(env.scroller, { key: "PageUp" }); + for (let i = 0; i < 8; i++) + await key(env.scroller, { key: "PageUp", repeat: true }); + assert.equal(starts, 1); + await key(env.scroller, { key: " ", shiftKey: true }); + assert.equal(starts, 2, "a distinct keypress can supply fresh upward intent"); +}); + +test("touch intent requires upward motion and does not rearm during a held finger pause", async (t) => { + const env = await setup(t); + let starts = 0; + let tryStart; + function Harness() { + tryStart = useHistoryBoundaryIntent( + env.hostRef, + () => { + starts++; + return true; + }, + () => {}, + ); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + const touch = async (name, y) => { + env.scroller.dispatchEvent( + new window.TouchEvent(name, { touches: [{ clientY: y }] }), + ); + await env.frame(); + }; + await touch("touchstart", 100); + tryStart(); + assert.equal(starts, 0, "touch down alone is not history intent"); + await touch("touchmove", 80); + assert.equal(starts, 0, "downward reading motion does not page"); + await touch("touchmove", 120); + assert.equal(starts, 1); + await env.frame(500); + await touch("touchmove", 150); + assert.equal( + starts, + 1, + "a held touch remains the same gesture across a pause", + ); + await touch("touchend", 150); + await touch("touchstart", 100); + await touch("touchmove", 150); + assert.equal(starts, 2); + await touch("touchcancel", 150); + tryStart(); + assert.equal(starts, 2); +}); + +test("scrollbar intent requires upward drag; click, cancellation and later layout cannot page", async (t) => { + const env = await setup(t); + let starts = 0; + let tryStart; + function Harness() { + tryStart = useHistoryBoundaryIntent( + env.hostRef, + () => { + starts++; + return true; + }, + () => {}, + ); + return null; + } + await act(async () => env.root.render(React.createElement(Harness))); + const pointer = async (name, target = env.scroller) => { + target.dispatchEvent(new window.Event(name, { bubbles: true })); + await env.frame(); + }; + await pointer("pointerdown"); + assert.equal(starts, 0, "clicking unused scroller space is not upward input"); + env.scroller.scrollTop = 20; + env.scroller.dispatchEvent(new window.Event("scroll")); + assert.equal(starts, 0, "downward scrollbar travel does not page"); + env.scroller.scrollTop = 10; + env.scroller.dispatchEvent(new window.Event("scroll")); + assert.equal(starts, 1); + await env.frame(500); + env.scroller.scrollTop = 0; + env.scroller.dispatchEvent(new window.Event("scroll")); + assert.equal(starts, 1, "one drag cannot consume multiple pages"); + await pointer("pointercancel", window); + await pointer("pointerdown"); + await pointer("pointercancel", window); + env.scroller.scrollTop = 0; + tryStart(); + assert.equal(starts, 1, "cancelled pointer cannot lend intent to layout"); +}); + +test("empty viewport fill is serial, receipt-gated and capped at three pages", async (t) => { + const env = await setup(t); + const emptyScroller = document.createElement("div"); + Object.defineProperties(emptyScroller, { + scrollHeight: { value: 600 }, + clientHeight: { value: 600 }, + }); + const scrollElementRef = { current: emptyScroller }; + let requests = 0; + let pager; + let revision = 1; + function Harness({ renderedRevision }) { + pager = useHistoryPagination({ + channelId: "a", + canLoad: true, + fillViewport: true, + renderedRevision, + scrollElementRef, + fetchOlder: async () => { + requests++; + return revision + 1; + }, + }); + return null; + } + const render = () => + act(async () => + env.root.render( + React.createElement(Harness, { renderedRevision: revision }), + ), + ); + await render(); + for (let i = 0; i < 8; i++) await env.frame(); + assert.equal(requests, 1); + for (let i = 0; i < 20; i++) await env.frame(); + assert.equal(requests, 1, "fill does not bypass visual acknowledgement"); + for (let step = 0; step < 3; step++) { + revision++; + await render(); + for (let i = 0; i < 10; i++) await env.frame(); + } + assert.equal(requests, 3, "an unfillable viewport cannot loop indefinitely"); + assert.equal(pager.isPending, false); + await act(async () => assert.equal(pager.start(), true)); + assert.equal(requests, 4, "the fill cap does not disable deliberate paging"); +}); + +const { useSendMessageMutation } = await import("../hooks.ts"); +const { relayClient } = await import("../../../shared/api/relayClient.ts"); +const { channelWindowKey, channelMessagesKey } = await import( + "../lib/messageQueryKeys.ts" +); +const { mergeLiveChannelWindowEvent } = await import( + "../lib/channelWindowStore.ts" +); +const { projectChannelWindowMessages } = await import( + "../lib/projectChannelWindow.ts" +); + +test("failed send removes only its optimistic row, retaining concurrent history and live writes", async (t) => { + const env = await setup(t); + const client = new QueryClient({ + defaultOptions: { + mutations: { retry: false, gcTime: Infinity }, + queries: { gcTime: Infinity }, + }, + }); + t.after(() => client.clear()); + let rejectSend; + let sendStarted; + const started = new Promise((resolve) => { + sendStarted = resolve; + }); + t.mock.method(relayClient, "sendMessage", () => { + sendStarted(); + return new Promise((_, reject) => { + rejectSend = reject; + }); + }); + const event = (id, created_at) => ({ + id, + created_at, + kind: 9, + pubkey: "p", + content: id, + tags: [["h", "a"]], + sig: "", + }); + const headRow = event("head", 100); + const cursor = { eventId: headRow.id, createdAt: 100 }; + const head = replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: headRow, thread: null }], + aux: [], + nextCursor: cursor, + hasMore: true, + }); + const key = channelWindowKey("a"); + client.setQueryData(key, head); + projectChannelWindowMessages(client, "a"); + let mutation; + function Harness() { + mutation = useSendMessageMutation( + { id: "a", channelType: "stream" }, + { pubkey: "p" }, + ); + return null; + } + await act(async () => + env.root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Harness), + ), + ), + ); + let result; + await act(async () => { + result = mutation + .mutateAsync({ content: "will fail" }) + .catch((error) => error); + await started; + }); + const pendingId = client.getQueryData(key).liveOverlay[0].id; + let current = appendOlderChannelWindow(client.getQueryData(key), { + startCursor: cursor, + rows: [{ event: event("older", 90), thread: null }], + aux: [], + nextCursor: null, + hasMore: false, + }); + current = mergeLiveChannelWindowEvent(current, event("live", 110)); + current = mergeLiveChannelWindowEvent(current, { + ...event("other-pending", 120), + pending: true, + }); + client.setQueryData(key, current); + projectChannelWindowMessages(client, "a"); + await act(async () => { + rejectSend(Error("offline")); + await result; + }); + const after = client.getQueryData(key); + assert.equal( + after.revision, + current.revision, + "send rollback cannot regress a publication receipt", + ); + assert.equal(after.pages.length, 2); + assert.equal( + after.liveOverlay.some((row) => row.id === pendingId), + false, + ); + assert.deepEqual( + after.liveOverlay.map((row) => row.id), + ["other-pending", "live"], + ); + assert.deepEqual( + client.getQueryData(channelMessagesKey("a")).map((row) => row.id), + ["older", "head", "live", "other-pending"], + ); +}); diff --git a/desktop/src/features/messages/ui/useAdmittedTimelineSnapshot.ts b/desktop/src/features/messages/ui/useAdmittedTimelineSnapshot.ts new file mode 100644 index 00000000000..663e499af28 --- /dev/null +++ b/desktop/src/features/messages/ui/useAdmittedTimelineSnapshot.ts @@ -0,0 +1,51 @@ +import * as React from "react"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages"; +import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages"; + +export type TimelineSnapshot = { + channelId: string | null; + messages: TimelineMessage[]; + historyExhausted: boolean; + historyRevision: number; + firstUnreadMessageId: string | null; + threadSummaries?: ReadonlyMap; + mainEntries?: MainTimelineEntry[]; +}; +const EMPTY: TimelineSnapshot = { + channelId: null, + messages: [], + historyExhausted: false, + historyRevision: 0, + firstUnreadMessageId: null, +}; + +/** Rows and structural metadata take the same concurrency/admission path. + * Holding only messages would mint dividers or regroup rows against old data. + */ +export function useAdmittedTimelineSnapshot({ + snapshot, + isAtBottom, + scrollElementRef, +}: { + snapshot: TimelineSnapshot; + isAtBottom: boolean; + scrollElementRef: { readonly current: HTMLElement | null }; +}) { + const deferred = React.useDeferredValue(snapshot, EMPTY); + const buffered = useBufferedTimelineMessages({ + channelId: snapshot.channelId, + isAtBottom, + messages: deferred.messages, + }); + const admitted = useSettleGatedPrependMessages({ + channelId: snapshot.channelId, + messages: buffered.messages, + meta: deferred, + bypass: isAtBottom, + scrollElementRef, + }); + return { deferred, admitted, pendingCount: buffered.pendingCount }; +} diff --git a/desktop/src/features/messages/ui/useHistoryBoundaryIntent.ts b/desktop/src/features/messages/ui/useHistoryBoundaryIntent.ts new file mode 100644 index 00000000000..71b5aa09bd7 --- /dev/null +++ b/desktop/src/features/messages/ui/useHistoryBoundaryIntent.ts @@ -0,0 +1,143 @@ +import * as React from "react"; + +const GESTURE_QUIET_MS = 180; +const UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]); + +/** Scroll/layout callbacks cannot create reader intent. One gesture can consume + * at most one history transaction, even when a fast page has already settled. + */ +export function useHistoryBoundaryIntent( + hostRef: React.RefObject, + onStartReached: (() => boolean) | undefined, + armMomentum: (started: boolean) => void, +) { + const stateRef = React.useRef({ + lastInput: -Infinity, + consumed: false, + eligible: false, + pointerDown: false, + touchDown: false, + }); + const startRef = React.useRef(onStartReached); + startRef.current = onStartReached; + const tryStart = React.useCallback(() => { + const state = stateRef.current; + if ( + !state.eligible || + state.consumed || + (!state.pointerDown && + performance.now() - state.lastInput >= GESTURE_QUIET_MS) + ) + return; + const scroller = hostRef.current?.firstElementChild; + if (!(scroller instanceof HTMLDivElement) || scroller.scrollTop > 200) + return; + if (startRef.current?.()) { + state.consumed = true; + armMomentum(true); + } + }, [armMomentum, hostRef]); + React.useLayoutEffect(() => { + const scroller = hostRef.current?.firstElementChild; + if (!(scroller instanceof HTMLDivElement)) return; + let frame = 0; + const input = (upward: boolean, fresh = false) => { + const state = stateRef.current; + const now = performance.now(); + if ( + fresh || + (!state.pointerDown && + !state.touchDown && + now - state.lastInput >= GESTURE_QUIET_MS) + ) + state.consumed = false; + state.lastInput = now; + state.eligible = upward; + cancelAnimationFrame(frame); + // Let default scrolling happen first. This also covers upward input at + // scrollTop=0, where no scroll event is emitted at all. + frame = requestAnimationFrame(tryStart); + }; + const wheel = (event: WheelEvent) => { + if (!event.ctrlKey) input(event.deltaY < 0); + }; + const key = (event: KeyboardEvent) => { + if ( + event.ctrlKey || + event.metaKey || + event.altKey || + !(event.target instanceof HTMLElement) || + event.target.closest("input,textarea,select,[contenteditable='true']") + ) + return; + if (UP_KEYS.has(event.key) || (event.key === " " && event.shiftKey)) + input(true, !event.repeat); + else if (["ArrowDown", "PageDown", "End", " "].includes(event.key)) + input(false); + }; + let previousOffset = 0; + const pointer = (event: PointerEvent) => { + if (event.target !== scroller || event.pointerType === "touch") return; + stateRef.current.pointerDown = true; + previousOffset = scroller.scrollTop; + // A press in empty space is not upward intent. Only subsequent upward + // scrollbar travel can consume this drag's transaction. + input(false, true); + }; + const pointerEnd = () => { + stateRef.current.pointerDown = false; + stateRef.current.eligible = false; + }; + const scroll = () => { + if (stateRef.current.pointerDown) { + stateRef.current.eligible = scroller.scrollTop < previousOffset; + previousOffset = scroller.scrollTop; + } + tryStart(); + }; + let touchY = 0; + const touchStart = (event: TouchEvent) => { + touchY = event.touches[0]?.clientY ?? 0; + stateRef.current.touchDown = true; + stateRef.current.consumed = false; + stateRef.current.eligible = false; + stateRef.current.lastInput = performance.now(); + }; + const touchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY ?? touchY; + input(nextY > touchY); + touchY = nextY; + }; + const touchEnd = () => { + stateRef.current.touchDown = false; + }; + const touchCancel = () => { + touchEnd(); + stateRef.current.eligible = false; + }; + scroller.addEventListener("wheel", wheel, { passive: true }); + scroller.addEventListener("keydown", key); + scroller.addEventListener("pointerdown", pointer, { passive: true }); + window.addEventListener("pointerup", pointerEnd, { passive: true }); + window.addEventListener("pointercancel", pointerEnd, { passive: true }); + scroller.addEventListener("scroll", scroll, { passive: true }); + scroller.addEventListener("touchstart", touchStart, { passive: true }); + scroller.addEventListener("touchmove", touchMove, { passive: true }); + scroller.addEventListener("touchend", touchEnd, { passive: true }); + scroller.addEventListener("touchcancel", touchCancel, { passive: true }); + return () => { + cancelAnimationFrame(frame); + scroller.removeEventListener("wheel", wheel); + scroller.removeEventListener("keydown", key); + scroller.removeEventListener("pointerdown", pointer); + window.removeEventListener("pointerup", pointerEnd); + window.removeEventListener("pointercancel", pointerEnd); + scroller.removeEventListener("scroll", scroll); + scroller.removeEventListener("touchstart", touchStart); + scroller.removeEventListener("touchmove", touchMove); + scroller.removeEventListener("touchend", touchEnd); + scroller.removeEventListener("touchcancel", touchCancel); + }; + }, [hostRef, tryStart]); + return tryStart; +} diff --git a/desktop/src/features/messages/ui/useHistoryPagination.ts b/desktop/src/features/messages/ui/useHistoryPagination.ts new file mode 100644 index 00000000000..48524085b2c --- /dev/null +++ b/desktop/src/features/messages/ui/useHistoryPagination.ts @@ -0,0 +1,158 @@ +import * as React from "react"; + +type Transaction = { channelId: string | null; revision: number | null }; + +/** Owns page admission from request through deferred DOM/measurement commit. + * This coordinator never writes a scroll offset; Virtua owns compensation. + */ +export function useHistoryPagination({ + channelId, + fetchOlder, + canLoad, + renderedRevision, + renderedChannelId = channelId ?? null, + scrollElementRef, + fillViewport = false, +}: { + channelId?: string | null; + fetchOlder?: () => Promise; + canLoad: boolean; + renderedRevision: number; + renderedChannelId?: string | null; + /** Allow at most three serial pages to fill a viewport without scroll range. */ + fillViewport?: boolean; + scrollElementRef: { readonly current: HTMLElement | null }; +}) { + const activeChannel = channelId ?? null; + const transactionRef = React.useRef(null); + const channelRef = React.useRef(activeChannel); + const fillRef = React.useRef({ count: 0, lastRevision: -1 }); + const [transaction, setTransaction] = React.useState( + null, + ); + if (channelRef.current !== activeChannel) { + channelRef.current = activeChannel; + transactionRef.current = null; + fillRef.current = { count: 0, lastRevision: -1 }; + } + const cancel = React.useCallback(() => { + transactionRef.current = null; + setTransaction(null); + }, []); + React.useEffect( + () => () => { + transactionRef.current = null; + }, + [], + ); + + const start = React.useCallback(() => { + if (!fetchOlder || !canLoad || transactionRef.current) return false; + const request: Transaction = { channelId: activeChannel, revision: null }; + // Synchronous reservation closes the gap before React publishes loading. + transactionRef.current = request; + setTransaction(request); + void (async () => { + try { + const revision = await fetchOlder(); + if (transactionRef.current !== request) return; + if (revision === undefined) { + // No-op, exhausted, canceled or a legacy non-window pager. + cancel(); + return; + } + const received = { ...request, revision }; + transactionRef.current = received; + setTransaction(received); + } catch (error) { + if (transactionRef.current !== request) return; + console.error("Failed to load timeline history", activeChannel, error); + cancel(); + } + })(); + return true; + }, [activeChannel, canLoad, cancel, fetchOlder]); + + React.useLayoutEffect(() => { + if ( + !transaction || + transactionRef.current !== transaction || + transaction.revision === null || + renderedChannelId !== transaction.channelId || + renderedRevision < transaction.revision + ) + return; + const scroller = scrollElementRef.current; + if (!scroller) { + cancel(); + return; + } + let frame = 0; + let stableFrames = 0; + let previous = ""; + const watch = () => { + if (transactionRef.current !== transaction) return; + const geometry = `${scroller.scrollTop}:${scroller.scrollHeight}:${scroller.clientHeight}`; + stableFrames = geometry === previous ? stableFrames + 1 : 0; + previous = geometry; + if (stableFrames >= 3) { + cancel(); + return; + } + frame = requestAnimationFrame(watch); + }; + frame = requestAnimationFrame(watch); + return () => cancelAnimationFrame(frame); + }, [ + cancel, + renderedChannelId, + renderedRevision, + scrollElementRef, + transaction, + ]); + + const isPending = + transaction !== null && + transactionRef.current === transaction && + transaction.channelId === activeChannel; + + React.useEffect(() => { + // State restarts fill when visual acknowledgement clears the reservation; + // the ref also closes the synchronous gap before that state render. + if (!fillViewport || !canLoad || isPending || transactionRef.current) + return; + const fill = fillRef.current; + if (fill.count >= 3 || fill.lastRevision === renderedRevision) return; + let frame = 0; + let previous = ""; + let stableFrames = 0; + const watch = () => { + const scroller = scrollElementRef.current; + if (!scroller || scroller.clientHeight <= 0 || transactionRef.current) + return; + if (scroller.scrollHeight > scroller.clientHeight + 1) return; + const geometry = `${scroller.scrollHeight}:${scroller.clientHeight}`; + stableFrames = geometry === previous ? stableFrames + 1 : 0; + previous = geometry; + if (stableFrames >= 3) { + if (start()) { + fill.count++; + fill.lastRevision = renderedRevision; + } + return; + } + frame = requestAnimationFrame(watch); + }; + frame = requestAnimationFrame(watch); + return () => cancelAnimationFrame(frame); + }, [ + canLoad, + fillViewport, + isPending, + renderedRevision, + scrollElementRef, + start, + ]); + + return { start, cancel, isPending }; +} diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 5c2521ecc79..37e04b7137e 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -1,7 +1,7 @@ import * as React from "react"; type UseLoadOlderOnScrollOptions = { - fetchOlder?: () => Promise; + fetchOlder?: () => Promise; hasOlderMessages: boolean; isLoading: boolean; scrollContainerRef: React.RefObject; diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs index b134b832afe..95cff39b2dc 100644 --- a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs +++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs @@ -37,23 +37,23 @@ test("passes appends through immediately", () => { ); }); -test("passes a simultaneous prepend+append (own send) through", () => { +test("holds a simultaneous prepend+append until explicit release", () => { assert.equal( selectSettleGatedMessages({ admitted: rows("a", "b"), next: rows("older-1", "a", "b", "sent"), }).kind, - "pass", + "hold", ); }); -test("passes deletions inside the admitted window through", () => { +test("holds a prepend with deletions inside the admitted window", () => { assert.equal( selectSettleGatedMessages({ admitted: rows("a", "b", "c"), next: rows("older-1", "a", "c"), }).kind, - "pass", + "hold", ); }); diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts index bad8d0f04a7..3743acb9ed8 100644 --- a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts +++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts @@ -27,24 +27,21 @@ import * as React from "react"; export const SETTLE_MOTION_WINDOW_MS = 100; export const SETTLE_FRAME_COUNT = 3; /** - * Upper bound on how long a fetched page may be withheld. Trackpad momentum - * decays in well under a second; a reader actively driving the scroller for - * this long has moved on, and admitting under continuous REAL input is safe — - * the dropped-write hazard is specific to the inertial momentum phase, which - * cannot outlive this deadline. + * There is deliberately no unconditional admission deadline. A fresh gesture + * can start at any age of the request; elapsed fetch time is not proof that + * WebKit has relinquished momentum. The held page remains available and is + * admitted as soon as real input and geometry settle. */ -export const SETTLE_HOLD_DEADLINE_MS = 4_000; export type SettleGateDecision = | { kind: "pass" } | { kind: "hold"; held: T[] }; /** - * Pure admission rule. Holds only a PURE history prepend: the next snapshot - * must be the admitted rows (same ids, possibly refreshed objects — edits and - * reactions keep rendering) with one or more new rows in front. Anything else - * — appends, deletions, authoritative replacements, channel resets — passes - * through immediately so the gate can never pin a stale dataset. + * Hold a history prefix even when deletion, live output or row regrouping + * accompanies it. Survivors may refresh while held, but structural metadata + * stays at the admitted publication until motion stops. A disjoint replacement + * is not history pagination and must not pin a stale channel. */ export function selectSettleGatedMessages({ admitted, @@ -54,17 +51,15 @@ export function selectSettleGatedMessages({ next: T[]; }): SettleGateDecision { if (admitted.length === 0) return { kind: "pass" }; - const prependCount = next.findIndex( - (message) => message.id === admitted[0].id, + const admittedIds = new Set(admitted.map((message) => message.id)); + const firstSurvivor = next.findIndex((message) => + admittedIds.has(message.id), ); - if (prependCount <= 0) return { kind: "pass" }; - if (next.length - prependCount !== admitted.length) return { kind: "pass" }; - for (let index = 0; index < admitted.length; index += 1) { - if (next[prependCount + index].id !== admitted[index].id) { - return { kind: "pass" }; - } - } - return { kind: "hold", held: next.slice(prependCount) }; + if (firstSurvivor <= 0) return { kind: "pass" }; + return { + kind: "hold", + held: next.filter((message) => admittedIds.has(message.id)), + }; } export function useSettleGatedPrependMessages({ @@ -72,6 +67,7 @@ export function useSettleGatedPrependMessages({ messages, meta, scrollElementRef, + bypass = false, }: { channelId?: string | null; messages: T[]; @@ -84,6 +80,8 @@ export function useSettleGatedPrependMessages({ */ meta: M; scrollElementRef: { readonly current: HTMLElement | null }; + /** Explicit latest/send/navigation intent may release held history. */ + bypass?: boolean; }): { messages: T[]; meta: M; isHoldingPrepend: boolean } { const admittedRef = React.useRef(messages); const admittedMetaRef = React.useRef(meta); @@ -96,10 +94,12 @@ export function useSettleGatedPrependMessages({ admittedMetaRef.current = meta; } - const decision = selectSettleGatedMessages({ - admitted: admittedRef.current, - next: messages, - }); + const decision: SettleGateDecision = bypass + ? { kind: "pass" } + : selectSettleGatedMessages({ + admitted: admittedRef.current, + next: messages, + }); const isHoldingPrepend = decision.kind === "hold"; let output: T[]; @@ -137,7 +137,6 @@ export function useSettleGatedPrependMessages({ return; } let frame: number | null = null; - const deadline = performance.now() + SETTLE_HOLD_DEADLINE_MS; // Assume motion at hold start: worst case this costs one quiet window // (~100ms) behind the fetching-older spinner when the reader was already // at rest; the alternative admits mid-fling if WebKit starves the first @@ -150,16 +149,15 @@ export function useSettleGatedPrependMessages({ }; scroller.addEventListener("scroll", markMotion, { passive: true }); scroller.addEventListener("wheel", markMotion, { passive: true }); + scroller.addEventListener("touchmove", markMotion, { passive: true }); + scroller.addEventListener("keydown", markMotion); const watch = () => { const scrollTop = scroller.scrollTop; settledFrames = Math.abs(scrollTop - previousScrollTop) < 0.5 ? settledFrames + 1 : 0; previousScrollTop = scrollTop; const quiet = performance.now() - lastMotionTs >= SETTLE_MOTION_WINDOW_MS; - if ( - (quiet && settledFrames >= SETTLE_FRAME_COUNT) || - performance.now() >= deadline - ) { + if (quiet && settledFrames >= SETTLE_FRAME_COUNT) { frame = null; admittedRef.current = latestMessagesRef.current; admittedMetaRef.current = latestMetaRef.current; @@ -172,6 +170,8 @@ export function useSettleGatedPrependMessages({ return () => { scroller.removeEventListener("scroll", markMotion); scroller.removeEventListener("wheel", markMotion); + scroller.removeEventListener("touchmove", markMotion); + scroller.removeEventListener("keydown", markMotion); if (frame !== null) cancelAnimationFrame(frame); }; }, [isHoldingPrepend, scrollElementRef]); diff --git a/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts b/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts index 910b251ac42..96a536d7895 100644 --- a/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts +++ b/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts @@ -4,16 +4,15 @@ export function useUpwardPaginationWheel( hostRef: React.RefObject, onWheel: () => void, ) { - const suppressRef = React.useRef(false); + const suppressUntilRef = React.useRef(Number.NEGATIVE_INFINITY); const lastUpwardWheelAtRef = React.useRef(Number.NEGATIVE_INFINITY); const clear = React.useCallback(() => { - suppressRef.current = false; + suppressUntilRef.current = Number.NEGATIVE_INFINITY; }, []); React.useLayoutEffect(() => { const scroller = hostRef.current?.firstElementChild; if (!(scroller instanceof HTMLDivElement)) return; - let releaseTimer: number | null = null; const handleWheel = (event: WheelEvent) => { // Ctrl+wheel belongs to browser zoom. It must not retire bottom intent or // arm upward-pagination momentum because it does not move the reader. @@ -21,23 +20,16 @@ export function useUpwardPaginationWheel( onWheel(); if (event.deltaY >= 0) { clear(); - if (releaseTimer !== null) window.clearTimeout(releaseTimer); - releaseTimer = null; return; } lastUpwardWheelAtRef.current = performance.now(); - if (!suppressRef.current) return; + if (performance.now() >= suppressUntilRef.current) return; event.preventDefault(); - if (releaseTimer !== null) window.clearTimeout(releaseTimer); - releaseTimer = window.setTimeout(() => { - clear(); - releaseTimer = null; - }, 80); + suppressUntilRef.current = performance.now() + 80; }; scroller.addEventListener("wheel", handleWheel, { passive: false }); return () => { scroller.removeEventListener("wheel", handleWheel); - if (releaseTimer !== null) window.clearTimeout(releaseTimer); }; }, [clear, hostRef, onWheel]); @@ -50,7 +42,9 @@ export function useUpwardPaginationWheel( scroller.scrollHeight - scroller.clientHeight > 400 && performance.now() - lastUpwardWheelAtRef.current < 120 ) { - suppressRef.current = true; + // Expiry starts with the triggering tick, even if it was the last + // tick of this gesture. No timer or future suppressed event is needed. + suppressUntilRef.current = performance.now() + 80; } }, [hostRef], diff --git a/desktop/src/features/messages/ui/virtuaKeyedPatch.test.mjs b/desktop/src/features/messages/ui/virtuaKeyedPatch.test.mjs new file mode 100644 index 00000000000..65f69f4f2c6 --- /dev/null +++ b/desktop/src/features/messages/ui/virtuaKeyedPatch.test.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import test from "node:test"; +import vm from "node:vm"; + +const require = createRequire(import.meta.url); +// Run the installed, patched distribution's real store, not stale source-map +// sources or a reimplementation. Both published module formats must agree. +// Load-bearing ordering in action 10: resolve the anchor and oldOffset from +// PRE-mutation sizes/offsets, then remap keys and replace the arrays. The mixed +// prepend/delete test below must fail if anchor selection moves after the swap. +for (const format of ["esm", "cjs"]) { + const file = require + .resolve("virtua") + .replace(/index\.cjs$/, format === "esm" ? "index.js" : "index.cjs"); + const source = readFileSync(file, "utf8"); + const from = source.indexOf( + format === "esm" ? "const u = null" : "const r = null", + ); + const to = + source.indexOf( + format === "esm" ? "}, H = setTimeout" : "}, k = setTimeout", + from, + ) + 1; + const createStore = vm.runInNewContext( + `${source.slice(from, to)}; ${format === "esm" ? "E" : "I"}`, + { + navigator: { userAgent: "", platform: "", maxTouchPoints: 0 }, + }, + ); + const bindingEnd = + source.indexOf( + format === "esm" ? "}, J = (e, t, o)" : "}, C = (e, t, o)", + to, + ) + 1; + const bindInput = vm.runInNewContext( + `${source.slice(from, bindingEnd)}; ${format === "esm" ? "B" : "T"}`, + { + navigator: { userAgent: "", platform: "", maxTouchPoints: 0 }, + setTimeout, + clearTimeout, + }, + ); + const update = (store, action, value) => + store[format === "esm" ? "B" : "q"](action, value); + const cache = (store) => Array.from(store[format === "esm" ? "_" : "S"]()[0]); + const make = () => { + const store = createStore(4, [40, 80, 120, 160]); + update(store, 4, 100); + update(store, 1, 135); // 15px inside c + update(store, 2); + return store; + }; + test(`${format}: mixed prepend/delete preserves measured key sizes and pixel offset`, () => { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "c", "d"], + [60, 50, 50, 50], + ]); + assert.deepEqual(cache(store), [60, 40, 120, 160]); + assert.deepEqual(Array.from(store.H()), [-20, true]); // c:120 ->100 + update(store, 1, 115); + update(store, 3, [ + [0, 90], + [2, 200], + [3, 170], + ]); + assert.deepEqual( + Array.from(store.H()), + [30, true], + "only sizes above the same visible row compensate", + ); + }); + test(`${format}: removed anchor selects next surviving neighbor at its old offset`, () => { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "d"], + [60, 50, 50], + ]); + assert.deepEqual(cache(store), [60, 40, 160]); + assert.deepEqual(Array.from(store.H()), [-140, true]); // d:240 ->100 + }); + test(`${format}: same-length marker replacement does not retain positional sizes`, () => { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["a", "marker", "c", "d"], + [50, 20, 50, 50], + ]); + assert.deepEqual(cache(store), [40, 20, 120, 160]); + assert.deepEqual(Array.from(store.H()), [-60, true]); + }); + test(`${format}: keyed prefix measures above the reading row, not its changed grouping or tail`, () => { + const store = createStore(3, [68, 48, 96]); + update(store, 4, 100); + update(store, 1, 0); + update(store, 5, [5, true, [60, 56, 68, 48, 96], true]); + assert.deepEqual(Array.from(store.H()), [116, true]); + update(store, 1, 116); + // DM intro/divider grow; the formerly-first message loses its author + // header after a prepend. Its top must not move when its own size shrinks. + update(store, 3, [ + [0, 100], + [1, 62], + [2, 48], + [4, 120], + ]); + assert.deepEqual(Array.from(store.H()), [46, true]); + }); + test(`${format}: unkeyed scalar and default estimates support prepend and append`, () => { + for (const estimate of [undefined, 64]) { + const store = createStore(3, estimate); + update(store, 4, 100); + update(store, 5, [5, true, estimate, false]); + assert.equal(store.H()[0], 2 * (estimate ?? 40)); + update(store, 5, [6, false, estimate, false]); + assert.equal(store.H()[0], 0); + assert.equal(cache(store).length, 6); + } + }); + test(`${format}: legacy unkeyed shift still compensates all retained sizes`, () => { + const store = createStore(3, [68, 48, 96]); + update(store, 4, 100); + update(store, 5, [5, true, [60, 56, 68, 48, 96]]); + store.H(); + update(store, 1, 116); + update(store, 3, [ + [0, 100], + [1, 62], + [2, 48], + [4, 120], + ]); + assert.deepEqual(Array.from(store.H()), [50, true]); + }); + test(`${format}: late measurements after scroll end preserve the resting viewport`, () => { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "c", "d"], + [60, 50, 50, 50], + ]); + store.H(); + update(store, 1, 115); + update(store, 2); + update(store, 3, [ + [0, 90], + [2, 200], + [3, 170], + ]); + assert.deepEqual(Array.from(store.H()), [30, false]); + }); + test(`${format}: removing the tail anchor falls back to its previous surviving key`, () => { + const store = make(); + update(store, 1, 250); + update(store, 2); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "b"], + [60, 50, 50], + ]); + assert.deepEqual(Array.from(store.H()), [60, true]); + assert.deepEqual(cache(store), [60, 40, 80]); + }); + test(`${format}: real input bindings retire anchoring while idle and ignore zoom/editing`, () => { + const handlers = new Map(); + const target = { + addEventListener: (name, handler) => handlers.set(name, handler), + removeEventListener: (name) => handlers.delete(name), + }; + const editable = { closest: () => ({}) }; + const events = [ + ["wheel", { deltaY: -1 }, true], + ["wheel", { deltaY: -1, ctrlKey: true }, false], + ["wheel", { deltaY: 0 }, false], + ["touchstart", {}, true], + ["pointerdown", { target }, true], + ["keydown", { key: "PageUp", target }, true], + ["keydown", { key: "Home", target: editable }, false], + ["keydown", { key: "ArrowUp", target, ctrlKey: true }, false], + ]; + for (const [name, event, retires] of events) { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "c", "d"], + [60, 50, 50, 50], + ]); + store.H(); + assert.equal(store.M(), false); + const binding = bindInput( + store, + target, + false, + () => 115, + () => {}, + ); + handlers.get(name)(event); + // Moving the reader into the inserted row means a new measurement of + // row a must not drag them back to the retired c anchor. + update(store, 1, 20); + update(store, 3, [[1, 100]]); + assert.deepEqual( + Array.from(store.H()), + retires ? [0, false] : [60, true], + `${name}: ${JSON.stringify(event)}`, + ); + binding[format === "esm" ? "A" : "J"](); + assert.equal(handlers.size, 0); + } + }); + test(`${format}: reader input retires keyed measurement intent`, () => { + const store = make(); + update(store, 10, [ + ["a", "b", "c", "d"], + ["new", "a", "c", "d"], + [60, 50, 50, 50], + ]); + store.H(); + update(store, 9); + update(store, 1, 20); + update(store, 3, [[1, 100]]); + assert.deepEqual(Array.from(store.H()), [0, false]); + }); +} diff --git a/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs index 9dcb07d121d..92db9b3479b 100644 --- a/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs +++ b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs @@ -21,13 +21,16 @@ test("reader wheel retires Virtua shift mode without publishing scroll end", () .filter(Boolean), ); assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]); + // Retirement must also work while idle: keyed reconciliation can be waiting + // for late measurement without an active native scroll. Zoom/zero motion are + // not reader input. The installed-distribution tests exercise these bindings. assert.match( patch, - /if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.q\(9\);\n\+\s+if \(f\) return;/, + /if \(t\.ctrlKey\) return;\n\+\s+\(o \? t\.deltaX : t\.deltaY\) && e\.q\(9\);\n\+\s+if \(!e\.M\(\)\) return;\n\+\s+if \(f\) return;/, ); assert.match( patch, - /if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.B\(9\);\n\+\s+if \(c\) return;/, + /if \(t\.ctrlKey\) return;\n\+\s+\(o \? t\.deltaX : t\.deltaY\) && e\.B\(9\);\n\+\s+if \(!e\.M\(\)\) return;\n\+\s+if \(c\) return;/, ); assert.doesNotMatch( patch, diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 2c602b88dc1..569c16b7201 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -1,10 +1,9 @@ -import { useCallback, useRef, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; import { channelWindowHasMore, - channelWindowHistoryExhausted, emptyChannelWindowStore, type ChannelWindowStore, } from "@/features/messages/lib/channelWindowStore"; @@ -14,43 +13,23 @@ import type { Channel } from "@/shared/api/types"; export function useFetchOlderMessages(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; - const [isFetchingOlder, setIsFetchingOlder] = useState(false); - const isFetchingOlderRef = useRef(false); - - // Whether older history remains, derived reactively from the authoritative - // window store rather than a private latch. A latch only reset on channelId - // change went stale on reconnect: `refreshNewestWindow` replaces the newest - // window with fresh `hasMore:true` rows, but the latch — flipped false when - // the pre-reconnect window exhausted — kept the scroll observer uninstalled, - // freezing paging at page one. Reading the store's tail `hasMore` self-heals: - // the observer re-arms the moment the refreshed window reports more history. - const windowKey = channelWindowKey(channelId ?? "none"); - const { data: hasOlderMessages = false } = useQuery({ - enabled: channelId !== null, - queryKey: windowKey, - select: channelWindowHasMore, - // Passive subscription: the window store is written by the messages query - // and the live subscription via setQueryData; this observer only reads. - queryFn: () => - queryClient.getQueryData(windowKey) ?? - emptyChannelWindowStore(), - }); - - // Distinct from `!hasOlderMessages`: an empty/unloaded window also reports - // "no more", but exhaustion requires a RESOLVED tail page proving the - // channel's beginning. Consumers gating UI on the history boundary (the - // oldest day divider) must use this, not the paging signal. - const { data: historyExhausted = false } = useQuery({ - enabled: channelId !== null, - queryKey: windowKey, - select: channelWindowHistoryExhausted, - queryFn: () => - queryClient.getQueryData(windowKey) ?? - emptyChannelWindowStore(), - }); + const scopeRef = useRef({ channelId, active: true, fetching: false }); + if (scopeRef.current.channelId !== channelId) { + scopeRef.current.active = false; + scopeRef.current = { channelId, active: true, fetching: false }; + } + const scope = scopeRef.current; + const [fetchingScope, setFetchingScope] = useState(null); + useEffect(() => { + scope.active = true; + return () => { + scope.active = false; + }; + }, [scope]); + const isFetchingOlder = fetchingScope === scope; const fetchOlder = useCallback(async () => { - if (!channelId || isFetchingOlderRef.current) { + if (!channelId || !scope.active || scope.fetching) { return; } const store = @@ -61,21 +40,22 @@ export function useFetchOlderMessages(channel: Channel | null) { return; } - isFetchingOlderRef.current = true; - setIsFetchingOlder(true); + scope.fetching = true; + setFetchingScope(scope); try { - await pageOlderMessagesUntilRowFloor( + const result = await pageOlderMessagesUntilRowFloor( queryClient, channelId, - () => channelId === channel?.id, + () => scope.active && scopeRef.current === scope, ); + return result.revision; } catch (error) { console.error("Failed to fetch older messages", channelId, error); } finally { - isFetchingOlderRef.current = false; - setIsFetchingOlder(false); + scope.fetching = false; + if (scope.active && scopeRef.current === scope) setFetchingScope(null); } - }, [channel?.id, channelId, queryClient]); + }, [channelId, queryClient, scope]); - return { fetchOlder, isFetchingOlder, hasOlderMessages, historyExhausted }; + return { fetchOlder, isFetchingOlder }; } diff --git a/desktop/tests/e2e/channel-dense-second-reach.spec.ts b/desktop/tests/e2e/channel-dense-second-reach.spec.ts index 34999be5076..1e782c51bb8 100644 --- a/desktop/tests/e2e/channel-dense-second-reach.spec.ts +++ b/desktop/tests/e2e/channel-dense-second-reach.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { pageOlderHistory } from "../helpers/timelineHistory"; // Lane 1c regression — the dense-second reachability wall. // @@ -45,7 +46,7 @@ test("dense single second beyond one window page is fully reachable via composit createdAt: denseSecond, }); } - // Newer window so the cold load (newest CHANNEL_HISTORY_LIMIT) does NOT + // Newer window so the cold load (newest 50 rows) does NOT // include the dense block — it must be paged into from scroll-up. for (let index = 0; index < newerCount; index += 1) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ @@ -88,21 +89,6 @@ test("dense single second beyond one window page is fully reachable via composit return found; }); - // Drive a real wheel-up gesture each pass: the older-history sentinel arms on - // a genuine leave→enter transition (IntersectionObserver), so a raw - // `scrollTop = 0` write on the virtualized container can fail to re-fire. - // A wheel event is what a real user issues and what the observer honors. - const wheelToTop = async () => { - for (let step = 0; step < 12; step += 1) { - const atTop = await timeline.evaluate( - (element) => (element as HTMLDivElement).scrollTop <= 1, - ); - if (atTop) break; - await page.mouse.wheel(0, -6000); - await page.waitForTimeout(40); - } - }; - const seen = new Set(); const collectRendered = async () => { for (const index of await renderedDenseIndices()) { @@ -110,39 +96,25 @@ test("dense single second beyond one window page is fully reachable via composit } }; - await timeline.hover(); - let stallStreak = 0; - for ( - let attempt = 0; - attempt < 120 && seen.size < DENSE_COUNT; - attempt += 1 - ) { - const before = seen.size; - await wheelToTop(); - // Each gesture pages a bounded step (one pass of the row-floor pager, which - // may itself engage the keyset drain). The sentinel disconnects while the - // prepend's index-restore owns scroll and only re-arms once settled, so - // poll for real growth rather than a fixed sleep. - try { - await expect - .poll( - async () => { - await collectRendered(); - return seen.size; - }, - { timeout: 4_000 }, - ) - .toBeGreaterThan(before); - } catch { - // No growth this pass — count it toward a genuine stall. - } + // Load through the tied second using separate, settled gestures. Always + // send input at the boundary: scrollTop=0 alone must not request history. + for (let attempt = 0; attempt < 25; attempt += 1) { + await pageOlderHistory(page); + await timeline.evaluate((element) => { + element.scrollTop = 0; + }); + await page.waitForTimeout(50); + if (await page.getByTestId("message-channel-intro").count()) break; + } + await expect(page.getByTestId("message-channel-intro")).toBeVisible(); + + // Walk the retained window in overlapping viewport-sized steps. Giant wheel + // jumps skip unmounted spans and cannot establish row reachability. + await collectRendered(); + for (let step = 0; step < 250 && seen.size < DENSE_COUNT; step += 1) { + await page.mouse.wheel(0, 300); + await page.waitForTimeout(40); await collectRendered(); - if (seen.size > before) { - stallStreak = 0; - } else { - stallStreak += 1; - if (stallStreak > 8) break; - } } // (a) Keyset paging actually engaged — the head load always issues diff --git a/desktop/tests/e2e/history-transactions.spec.ts b/desktop/tests/e2e/history-transactions.spec.ts new file mode 100644 index 00000000000..b82a948dd27 --- /dev/null +++ b/desktop/tests/e2e/history-transactions.spec.ts @@ -0,0 +1,400 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +import { + pageOlderHistory, + startOlderHistory, + waitForHistorySettled, +} from "../helpers/timelineHistory"; + +async function visibleAnchor(page: Page) { + return page.getByTestId("message-timeline").evaluate((element) => { + const top = element.getBoundingClientRect().top; + const row = [ + ...element.querySelectorAll("[data-message-id]"), + ].find((row) => row.getBoundingClientRect().bottom > top + 60); + if (!row?.dataset.messageId) throw Error("No visible anchor"); + return { + id: row.dataset.messageId, + top: row.getBoundingClientRect().top - top, + }; + }); +} + +async function anchorTop(page: Page, id: string) { + return page.getByTestId("message-timeline").evaluate((element, id) => { + const row = element.querySelector(`[data-message-id="${CSS.escape(id)}"]`); + return row + ? row.getBoundingClientRect().top - element.getBoundingClientRect().top + : null; + }, id); +} + +async function beginAnchorTrace(page: Page, id: string) { + await page.evaluate((id) => { + const trace: Array = []; + Object.assign(window, { __HISTORY_ANCHOR_TRACE__: trace }); + const sample = () => { + const scroller = document.querySelector( + '[data-testid="message-timeline"]', + ); + const row = scroller?.querySelector( + `[data-message-id="${CSS.escape(id)}"]`, + ); + trace.push( + row && scroller + ? row.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + : null, + ); + if (trace.length < 600) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, id); +} + +async function assertAnchorTrace(page: Page, top: number) { + const trace = await page.evaluate( + () => + (window as unknown as { __HISTORY_ANCHOR_TRACE__: Array }) + .__HISTORY_ANCHOR_TRACE__, + ); + expect(trace.length).toBeGreaterThan(3); + expect( + trace.every((value) => value !== null), + JSON.stringify(trace), + ).toBe(true); + expect( + Math.max(...trace.map((value) => Math.abs((value ?? Infinity) - top))), + JSON.stringify(trace), + ).toBeLessThan(5); +} + +for (const race of [false, true]) { + test(`reconnect preserves deep history${race ? " with an older request in flight" : ""}`, async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-deep-history").click(); + await expect( + page.getByTestId("message-timeline").locator("[data-message-id]").first(), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "deep-history", + }) ?? false, + ), + ) + .toBe(true); + for (let step = 0; step < 4; step++) await pageOlderHistory(page); + await page.evaluate(() => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 800 }, + }; + }); + if (race) { + await startOlderHistory(page); + // Wheel delivery is asynchronous; sample the resting anchor only after + // the input reached the boundary, still before the delayed response. + await expect + .poll(() => + page + .getByTestId("message-timeline") + .evaluate((element) => element.scrollTop), + ) + .toBe(0); + } + const anchor = await visibleAnchor(page); + await beginAnchorTrace(page, anchor.id); + const headsBefore = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => + entry.command === "get_channel_window" && + !(entry.payload as { cursor?: unknown })?.cursor, + ).length, + ); + await page.evaluate(() => { + // Move page zero's cursor, forcing a fresh bridging page instead of + // attaching the old cursor chain to an unrelated head. + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "deep-history", + content: "head moved while reading", + createdAt: Math.floor(Date.now() / 1000) + 1, + }); + window.__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?.(); + }); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => + entry.command === "get_channel_window" && + !(entry.payload as { cursor?: unknown })?.cursor, + ).length, + ), + ) + .toBeGreaterThan(headsBefore); + await waitForHistorySettled(page); + // Head revalidation may need several fresh cursor windows. Its staged + // publication must never shrink the reader to the newest 50 rows. + await page.waitForTimeout(4500); + expect(await anchorTop(page, anchor.id)).not.toBeNull(); + await assertAnchorTrace(page, anchor.top); + // A retired request/gesture cannot poison subsequent intentional paging. + await pageOlderHistory(page); + }); +} + +test("DM exhaustion adds intro and date markers without displacing the reading row", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + await page.evaluate(() => + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ + channelName: "alice-tyler", + count: 80, + lineCount: 2, + }), + ); + await page.getByTestId("channel-alice-tyler").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await expect(page.getByTestId("message-dm-intro")).toHaveCount(0); + await page.evaluate(() => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 600 }, + }; + }); + await startOlderHistory(page); + // The browser applies wheel scrolling asynchronously. Start the stationary + // anchor oracle after that input has landed, still before the delayed page. + await expect + .poll(() => timeline.evaluate((element) => element.scrollTop)) + .toBe(0); + const anchor = await visibleAnchor(page); + await beginAnchorTrace(page, anchor.id); + await waitForHistorySettled(page); + await assertAnchorTrace(page, anchor.top); + await timeline.evaluate((element) => { + element.scrollTop = 0; + }); + await expect(page.getByTestId("message-dm-intro")).toBeVisible(); +}); + +test("deletion and live metadata during a prepend preserve the surviving reading row", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.evaluate(() => { + for (let index = 0; index < 240; index++) + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + content: `Reading fixture ${index}`, + id: index.toString(16).padStart(64, "0"), + createdAt: Math.floor(Date.now() / 1000) - 240 + index, + }); + }); + await page.getByTestId("channel-engineering").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "engineering", + kind: 5, + }) ?? false, + ), + ) + .toBe(true); + await pageOlderHistory(page); + await page.evaluate(() => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 800 }, + }; + }); + await startOlderHistory(page); + await expect + .poll(() => timeline.evaluate((element) => element.scrollTop)) + .toBe(0); + const anchor = await visibleAnchor(page); + const victim = await timeline + .locator("[data-message-id]") + .nth(3) + .getAttribute("data-message-id"); + expect(victim).not.toBe(anchor.id); + await beginAnchorTrace(page, anchor.id); + await page.evaluate( + ({ victim, rootId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + content: "", + kind: 5, + extraTags: [["e", victim]], + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + content: "live output held behind latest", + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + kind: 39005, + extraTags: [["e", rootId]], + content: JSON.stringify({ + reply_count: 2, + descendant_count: 2, + last_reply_at: Math.floor(Date.now() / 1000), + participants: [], + }), + }); + }, + { victim, rootId: anchor.id }, + ); + await expect(timeline.locator(`[data-message-id="${victim}"]`)).toHaveCount( + 0, + ); + await waitForHistorySettled(page); + await assertAnchorTrace(page, anchor.top); + // Late intrinsic reflow above the resting reader, after scrollend has retired + // transaction mode. The ordinary Virtua measurement path must also preserve it. + await timeline.evaluate((element, anchorId) => { + const anchor = element + .querySelector(`[data-message-id="${anchorId}"]`) + ?.closest("[data-timeline-item-key]"); + const rows = [ + ...element.querySelectorAll("[data-timeline-item-key]"), + ]; + const above = rows[rows.indexOf(anchor as HTMLElement) - 1]; + if (!above) throw Error("Expected mounted row above anchor after prepend"); + above.style.paddingBottom = "73px"; + }, anchor.id); + await page.waitForTimeout(300); + await assertAnchorTrace(page, anchor.top); + await pageOlderHistory(page); +}); + +for (const recovery of [ + "Retry", + "Load latest", + "Load unchanged latest", +] as const) { + test(`failed refresh keeps the reading window and ${recovery} recovers`, async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-deep-history").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "deep-history", + }) ?? false, + ), + ) + .toBe(true); + for (let step = 0; step < 3; step++) await pageOlderHistory(page); + const anchor = await visibleAnchor(page); + await beginAnchorTrace(page, anchor.id); + await page.evaluate((recovery) => { + const w = window as typeof window & { + __FAIL_HISTORY_REFRESH__?: boolean; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + w.__FAIL_HISTORY_REFRESH__ = true; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + w.__TAURI_INTERNALS__.invoke = (command, payload, options) => { + if (command === "get_channel_window" && w.__FAIL_HISTORY_REFRESH__) + return Promise.reject(new Error("forced history refresh failure")); + return original(command, payload, options); + }; + if (recovery !== "Load unchanged latest") + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "deep-history", + content: "latest refresh recovery row", + createdAt: Math.floor(Date.now() / 1000) + 1, + }); + window.__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?.(); + }, recovery); + const notice = page.getByTestId("history-refresh-error"); + await expect(notice).toContainText( + "Your loaded history is still available.", + ); + // A second failure must remain visible/retryable without discarding rows. + await notice + .getByRole("button", { + name: recovery === "Retry" ? "Retry" : "Load latest", + exact: true, + }) + .click(); + await expect(notice).toBeVisible(); + // Observe a full request cycle, not the enabled render before invalidation. + await expect( + notice.getByRole("button", { name: "Retry", exact: true }), + ).toBeDisabled(); + // Keep the failure switch on through Query's automatic retry. Otherwise + // it can recover and remove the banner before the next explicit click. + await expect( + notice.getByRole("button", { name: "Retry", exact: true }), + ).toBeEnabled(); + await assertAnchorTrace(page, anchor.top); + expect(await anchorTop(page, anchor.id)).not.toBeNull(); + await page.evaluate(() => { + ( + window as typeof window & { __FAIL_HISTORY_REFRESH__?: boolean } + ).__FAIL_HISTORY_REFRESH__ = false; + }); + await notice + .getByRole("button", { + name: recovery === "Retry" ? "Retry" : "Load latest", + exact: true, + }) + .click(); + await expect(notice).toHaveCount(0); + await waitForHistorySettled(page); + if (recovery === "Retry") { + await assertAnchorTrace(page, anchor.top); + await pageOlderHistory(page); + } else { + if (recovery !== "Load unchanged latest") + await expect(timeline).toContainText("latest refresh recovery row"); + await expect + .poll(() => + timeline.evaluate((element) => + Math.abs( + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ), + ) + .toBeLessThan(5); + // Explicit replacement retires the old transaction; paging remains usable. + await pageOlderHistory(page); + } + }); +} diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6f..f8981a88c1c 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { + pageOlderHistory, + startOlderHistory, +} from "../helpers/timelineHistory"; // First-pass settle budget for a full channel-history prepend. CI Linux font // rasterization can leave the restored anchor a subpixel off the local value @@ -182,13 +186,8 @@ test("preserves user scroll while older channel history loads", async ({ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); - // Use the `deep-history` channel: its store is seeded with 600 messages, - // more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows - // to the newest 300 and leaves ~300 genuinely older messages behind the - // `until` cursor. A shallow seed (store < 300) is fully drained by the cold - // load, so the wheel `fetchOlder` returns only already-cached duplicates that - // dedup to zero net growth -- the anchor never has a real prepend to hold and - // the assertion would measure virtualizer re-measure, not scroll preservation. + // 600 seed rows, with one 50-row head on cold load: every continuation + // below must add real history, not merely remeasure cached duplicates. await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); const timeline = page.getByTestId("message-timeline"); @@ -214,24 +213,12 @@ test("preserves user scroll while older channel history loads", async ({ return Number.isFinite(min) ? min : null; }); - // PHASE 1 -- walk into mid-history with NO history delay. Force the timeline - // to its top and wait for an older rendered index after each fetch. A wheel - // issued while prepend restoration owns the sentinel can be swallowed, which - // made a fixed gesture loop fail before exercising the anchor invariant. - // Stop in mid-history so phase 2 still has a genuine older page to fetch above - // the reading anchor. - const scrollToTop = async () => - timeline.evaluate((element) => { - const container = element as HTMLDivElement; - container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); - container.scrollTop = 0; - container.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - + // Walk into mid-history. Each gesture waits for visual acknowledgement, + // not merely a newly mounted row from scrolling within the previous page. let deepest = (await oldestRenderedIndex()) ?? Number.POSITIVE_INFINITY; for (let pageIndex = 0; pageIndex < 10 && deepest >= 400; pageIndex += 1) { const previousDeepest = deepest; - await scrollToTop(); + await pageOlderHistory(page); await expect .poll(async () => (await oldestRenderedIndex()) ?? previousDeepest, { timeout: 5_000, @@ -270,19 +257,9 @@ test("preserves user scroll while older channel history loads", async ({ const oldestBeforeLanding = await oldestRenderedIndex(); expect(oldestBeforeLanding).not.toBeNull(); - // Move the top sentinel out of its trigger band after the phase-1 climb so - // returning to it is a fresh continuation gesture. - await page.mouse.wheel(0, 1_500); - await page.waitForTimeout(100); - - // Re-enter the top sentinel and wait for the delayed request to start. Drive - // the actual scroll container because wheel input can arrive while prepend - // restoration still owns the sentinel and be discarded. - for (let attempt = 0; attempt < 50; attempt += 1) { - if ((await inflightCount()) > 0) break; - await scrollToTop(); - await page.waitForTimeout(50); - } + // A fresh gesture starts exactly one delayed page after the prior visual + // transaction has settled. Keep it in flight for the anchor assertion. + await startOlderHistory(page); expect(await inflightCount()).toBeGreaterThan(0); // Capture the first-visible row id AFTER the fire wheel but WHILE the page is @@ -1316,19 +1293,12 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( return element && element.scrollHeight > element.clientHeight * 3; }); - // Land a genuine prepend first. This is what turns `shift` on; subsequent - // ordinary list updates and measurements must happen with it cleared. + // Land a genuine server page first. Live events older than an open window's + // boundary are intentionally not admitted; injecting them would not exercise + // prepend compensation. The 180 seeded rows leave real history to request. const scrollHeightBeforePrepend = (await getTimelineMetrics(page)) .scrollHeight; - await page.evaluate(() => { - for (let index = 0; index < 100; index += 1) { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", - content: `prepended settle row ${index}\nolder line two ${index}\nolder line three ${index}`, - createdAt: 1_699_999_000 + index, - }); - } - }); + await pageOlderHistory(page); await expect .poll(() => getTimelineMetrics(page).then((metrics) => metrics.scrollHeight), @@ -1689,56 +1659,16 @@ test("channel intro stays hidden while paginating past the timeline cap", async ); }); - // Poll for real progress instead of a fixed sleep: a slow CI shard's fetch - // round-trip outlasts a hard delay and reads as a false stall. - const waitForOlderHistoryProgress = async (previousDeepest: number) => { - try { - await expect - .poll(async () => (await oldestRenderedIndex()) ?? previousDeepest, { - timeout: 10_000, - }) - .toBeLessThan(previousDeepest); - } catch { - // No advancement within the window: treat as a stall, not a failure. - } - return oldestRenderedIndex(); - }; - - // Drive scrollTop to 0 each pass instead of `mouse.wheel`: the older-history - // sentinel disconnects while a prepend's index-restore owns scroll and only - // re-arms once it settles, so a wheel issued during that window is swallowed - // and reads as a false stall. Forcing the top guarantees the re-armed - // sentinel re-fires and the next older page fetches. - const scrollToTop = async () => - timeline.evaluate((element) => { - const container = element as HTMLDivElement; - container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); - container.scrollTop = 0; - container.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - await timeline.hover(); let deepest = Number.POSITIVE_INFINITY; - let stallStreak = 0; - for (let attempt = 0; attempt < 200 && deepest > 0; attempt += 1) { - await scrollToTop(); - const current = await waitForOlderHistoryProgress(deepest); - if ((current ?? Number.POSITIVE_INFINITY) > 50) { - expect(await isIntroHeaderInViewport()).toBe(false); - } - - if (current !== null && current < deepest) { - deepest = current; - stallStreak = 0; - } else { - // No advance within the 10s settle window. `waitForOlderHistoryProgress` - // already absorbs a slow fetch round-trip, so each no-advance pass is a - // genuine miss (sentinel still owned, or true end-of-history). Allow a - // generous streak before bailing so a few owned-window passes near the - // start can't end the loop short of index 0. - stallStreak += 1; - if (stallStreak > 15) break; - } + for (let attempt = 0; attempt < 50 && deepest >= 50; attempt += 1) { + await pageOlderHistory(page); + const previous = deepest; + await expect + .poll(async () => (await oldestRenderedIndex()) ?? previous) + .toBeLessThan(previous); + deepest = (await oldestRenderedIndex()) ?? previous; + if (deepest > 50) expect(await isIntroHeaderInViewport()).toBe(false); } // Reached deep history (near the true start) without the cap evicting the @@ -1791,6 +1721,7 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a await timeline.hover(); await timeline.evaluate((element) => { const timelineElement = element as HTMLDivElement; + timelineElement.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); timelineElement.scrollTop = 150; timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); }); @@ -1857,6 +1788,7 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll" // timeline at scrollTop 0, which is the one position this test must avoid. await timeline.evaluate((element) => { const timelineElement = element as HTMLDivElement; + timelineElement.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); timelineElement.scrollTop = 150; timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); }); @@ -1981,11 +1913,10 @@ test("one scroll-up gesture pages older history once, not to the channel top", a const pagesFetched = await fetchCount(); const deepest = await oldestRenderedIndex(); - // One gesture should yield a small, bounded number of pages — not dozens. - // pageOlderMessagesUntilRowFloor may fetch up to MAX_BATCHES_PER_FETCH (3) - // relay pages to satisfy one visible row floor, so allow that ceiling plus a - // little slack; a cascade blows far past it. - expect(pagesFetched).toBeLessThanOrEqual(4); + // One logical load is exactly one server page. Neither zero work nor + // sequential requests hidden behind a single network lock is acceptable. + expect(pagesFetched).toBe(1); + expect(deepest).not.toBeNull(); // And it must NOT have reached the oldest seeded root on its own. expect(deepest ?? Number.POSITIVE_INFINITY).toBeGreaterThan(50); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index f7a44f05c72..90b6ea9a095 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -588,18 +588,31 @@ test.describe("list virtualization", () => { capturedRestAnchor: restAnchor !== null, sawSpinnerDuringHold, anchorDriftAfterCommit, + baseHeight, + finalHeight: s.scrollHeight, + finalOffset: s.scrollTop, + requests: + (window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }) + .__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, + inflight: + (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) + .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, }; }); - // Trip the boundary, then keep real wheel input flowing DOWN (away from - // the boundary) through and well past the 300ms fetch resolution — the - // mid-gesture window in which the ungated build commits the page. + // Position near the boundary; the upward wheel supplies reader intent. + // A programmatic scroll alone must not start a page (layout/compensation + // scrolls otherwise cascade). Keep wheel input flowing DOWN through fetch. await timeline.evaluate((element) => { element.scrollTop = 150; }); const box = await timeline.boundingBox(); if (!box) throw new Error("timeline has no bounding box"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -30); + await expect( + page.getByTestId("message-timeline-fetching-older"), + ).toBeVisible(); for (let burst = 0; burst < 30; burst += 1) { await page.mouse.wheel(0, 30); await page.waitForTimeout(40); @@ -607,7 +620,7 @@ test.describe("list virtualization", () => { const trace = await tracePromise; // The page must eventually commit — the gate defers, never strands. - expect(trace.commit).not.toBeNull(); + expect(trace.commit, JSON.stringify(trace)).not.toBeNull(); // The commit landed only after input quiesced. On the ungated build the // deferred snapshot flushes as soon as the fetch resolves — between wheel // bursts, a gap far below the quiet window — so this line is the red/green diff --git a/desktop/tests/helpers/timelineHistory.ts b/desktop/tests/helpers/timelineHistory.ts new file mode 100644 index 00000000000..f9eeb6c4af1 --- /dev/null +++ b/desktop/tests/helpers/timelineHistory.ts @@ -0,0 +1,42 @@ +import { expect, type Page } from "@playwright/test"; + +export const olderWindowRequests = (page: Page) => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => + entry.command === "get_channel_window" && + (entry.payload as { cursor?: unknown } | null)?.cursor != null, + ).length, + ); + +/** A mounted older row is not a page-commit acknowledgement. Wait for the + * transaction indicator, then leave a quiet interval between reader gestures. + */ +export async function waitForHistorySettled(page: Page) { + await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount( + 0, + { timeout: 10_000 }, + ); + await page.waitForTimeout(250); +} + +/** Position the reader, then issue actual input even if already at the boundary. + * Programmatic positioning alone must not fetch; one gesture gets one window. + */ +export async function startOlderHistory(page: Page) { + await waitForHistorySettled(page); + const before = await olderWindowRequests(page); + const timeline = page.getByTestId("message-timeline"); + await timeline.hover(); + await timeline.evaluate((element) => { + element.scrollTop = 150; + }); + await page.mouse.wheel(0, -200); + await expect.poll(() => olderWindowRequests(page)).toBe(before + 1); +} + +export async function pageOlderHistory(page: Page) { + await startOlderHistory(page); + await waitForHistorySettled(page); +} diff --git a/patches/virtua@0.49.3.patch b/patches/virtua@0.49.3.patch index 5debe418ebd..2134607bedf 100644 --- a/patches/virtua@0.49.3.patch +++ b/patches/virtua@0.49.3.patch @@ -1,20 +1,20 @@ diff --git a/lib/index.cjs b/lib/index.cjs -index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1315bb55b 100644 +index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..64a9ede86c30cf6f4214616e09cb6fec7be2bf94 100644 --- a/lib/index.cjs +++ b/lib/index.cjs -@@ -35,10 +35,18 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -35,19 +35,28 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, v(e, s) <= t ? (n = s, o = s + 1) : r = s - 1; } return c(n, 0, e.l - 1); -}, w = (e, t, o) => { - const r = t - e.l; -- return e.i = o ? -1 : n(t - 1, e.i), e.l = t, r > 0 ? (h(e.u, r), h(e.t, r, o), +- return e.i = o ? -1 : n(t - 1, e.i), e.l = t, r > 0 ? (h(e.u, r), h(e.t, r, o), - e.o * r) : (e.u.splice(r), (o ? e.t.splice(0, -r) : e.t.splice(r)).reduce((t, o) => t - (-1 === o ? e.o : o), 0)); +}, w = (e, t, o, r) => { + const s = t - e.l; + if (e.i = o ? -1 : n(t - 1, e.i), e.l = t, s > 0) { + h(e.u, s); -+ if (r) { ++ if (Array.isArray(r)) { + const t = o ? r.slice(0, s) : r.slice(r.length - s); + e.t[o ? "unshift" : "push"](...t); + return t.reduce((e, t) => e + t, 0); @@ -24,8 +24,10 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1 + return e.u.splice(s), (o ? e.t.splice(0, -s) : e.t.splice(s)).reduce((t, o) => t - (-1 === o ? e.o : o), 0); }, S = "undefined" != typeof window, m = e => e.documentElement, $ = e => e.ownerDocument, z = e => e.defaultView, b = /*#__PURE__*/ a(() => !!/iP(hone|od|ad)/.test(navigator.userAgent) || "MacIntel" === navigator.platform && navigator.maxTouchPoints > 0), y = /*#__PURE__*/ a(() => "scrollBehavior" in m(document).style), x = e => s(e.h(), e.p()), I = (e, t = 40, o = 0, l, c = !1) => { let u = !!o, d = 1, a = 0, S = 0, m = 0, $ = 0, z = 0, y = 0, x = 0, I = 0, k = r, R = [ 0, u ? s(o - 1, 0) : -1 ], T = 0, C = !1; ++ let keyedAnchor = 0; const M = ((e, t, o) => ({ -@@ -47,7 +55,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + o: t, + t: o ? h(o.slice(0, n(e, o.length)), s(0, e - o.length)) : h([], e), l: e, i: -1, u: h([], e + 1) @@ -34,37 +36,111 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1 if (r = n(r, e.l - 1), v(e, r) <= t) { const n = _(e, o, r); return [ _(e, t, r, n), n ]; -@@ -147,7 +155,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -88,7 +97,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + p: () => a, + O: () => S, + h: q, +- H: () => (y = $, $ = 0, [ y, 2 === I ]), ++ H: () => (y = $, $ = 0, [ y, 2 === I || 3 === I ]), + W: (e, t) => { + const o = [ e, t ]; + return O.add(o), () => { +@@ -120,7 +129,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + if (!e.length) break; + L(e.reduce((e, [t, o]) => { + let r; +- if (2 === I) r = !0; else if (k && 1 === I) r = t < k[0]; else { ++ if (3 === I) r = t < keyedAnchor; else if (2 === I) r = !0; else if (k && 1 === I) r = t < k[0]; else { + const e = E(), o = B(t), n = J(t); + r = 1 !== x && 0 === I ? o + n <= e : o < e && o + n < e + a; + } +@@ -147,7 +156,9 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, break; - + case 5: - t[1] ? (L(w(M, t[0], !0)), I = 2, l = 1) : (w(M, t[0]), l = 1); -+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), I = 0, l = 1); ++ // Exact-prefix shift with a keyed reading anchor (see ESM). ++ if (t[1] && t[3]) keyedAnchor = _(M, s(0, H())) + t[0] - M.l; ++ t[1] ? (L(w(M, t[0], !0, t[2])), I = t[3] ? 3 : 2, l = 1) : (w(M, t[0], !1, t[2]), I = 0, l = 1); break; - + case 6: -@@ -160,6 +168,10 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, - +@@ -160,6 +171,36 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + case 8: k = W(t, t + a), l = 1; + break; + ++ // Keyed structural update; symmetric with the ESM store. ++ case 10: { ++ const [previousKeys, nextKeys, estimates] = t; ++ const nextIndex = new Map(nextKeys.map((key, index) => [key, index])); ++ let anchor = _(M, s(0, H())); ++ const originalAnchor = anchor; ++ while (anchor < previousKeys.length && !nextIndex.has(previousKeys[anchor])) anchor++; ++ if (anchor === previousKeys.length) { ++ anchor = originalAnchor - 1; ++ while (anchor >= 0 && !nextIndex.has(previousKeys[anchor])) anchor--; ++ } ++ const oldOffset = anchor >= 0 ? v(M, anchor) : 0; ++ const sizes = new Map(previousKeys.map((key, index) => [key, M.t[index]])); ++ M.t = nextKeys.map((key, index) => sizes.has(key) ? sizes.get(key) : Array.isArray(estimates) ? estimates[index] : -1); ++ M.l = nextKeys.length; ++ M.i = -1; ++ M.u = h([], M.l + 1); ++ keyedAnchor = anchor >= 0 ? nextIndex.get(previousKeys[anchor]) : 0; ++ R = [keyedAnchor, keyedAnchor]; ++ k = r; ++ if (anchor >= 0) L(v(M, keyedAnchor) - oldOffset); ++ I = 3; ++ l = 3; ++ break; ++ } ++ + case 9: + I = 0; } l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => { l & e && t(n); -@@ -186,6 +198,8 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -185,15 +226,25 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + })(), g = () => { c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h(); }, p = t => { - if (f || !e.M() || t.ctrlKey) return; -+ if (!e.M() || t.ctrlKey) return; -+ e.q(9); ++ if (t.ctrlKey) return; ++ (o ? t.deltaX : t.deltaY) && e.q(9); ++ if (!e.M()) return; + if (f) return; const r = l() - c; 150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0); }, v = () => { -@@ -266,7 +279,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +- u = !0, d = a = !1; ++ e.q(9), u = !0, d = a = !1; + }, _ = () => { + u = !1, b() && (d = !0); + }; +- return t.addEventListener("scroll", g), t.addEventListener("wheel", p, { ++ const pointer = event => { if (event.target === t) e.q(9); }; ++ const key = event => { ++ if (event.ctrlKey || event.metaKey || event.altKey || ++ event.target?.closest?.("input,textarea,select,[contenteditable='true']")) return; ++ if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) e.q(9); ++ }; ++ return t.addEventListener("pointerdown", pointer, { passive: !0 }), ++ t.addEventListener("keydown", key), t.addEventListener("scroll", g), t.addEventListener("wheel", p, { + passive: !0 + }), t.addEventListener("touchstart", v, { + passive: !0 +@@ -202,7 +253,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + }), { + J: () => { + t.removeEventListener("scroll", g), t.removeEventListener("wheel", p), t.removeEventListener("touchstart", v), +- t.removeEventListener("touchend", _), h.B(); ++ t.removeEventListener("touchend", _), h.B(), t.removeEventListener("pointerdown", pointer), t.removeEventListener("keydown", key); + }, + A: () => { + const [t, o] = e.H(); +@@ -266,7 +317,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, e[l] = t; }); } @@ -73,46 +149,74 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1 }), n[1](!0); }, v() { -@@ -519,11 +532,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -518,12 +569,21 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + return L(e, t), t; })(e); return [ e => t[e], t.length ]; - }, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => { +-}, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => { - const [$, z] = V(r, n), b = t.useRef(null), y = t.useRef(!!g), k = X(w), R = X(S), [T, C, O, E] = P(() => { - const e = !!c, t = I(z, i, g, a, !i); -+ const [$, z] = V(r, n), A = t.useMemo(() => "function" == typeof i ? Array.from({length: z}, (e, t) => i(n[t], t)) : i, [ i, n, z ]), b = t.useRef(null), y = t.useRef(!!g), k = X(w), R = X(S), [T, C, O, E] = P(() => { -+ const e = !!c, t = I(z, A, g, a, !A); ++}, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, itemKey, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => { ++ const [$, z] = V(r, n), itemEstimates = t.useMemo(() => "function" == typeof i ? Array.from({length: z}, (e, t) => i(n[t], t)) : i, [ i, n, z ]), b = t.useRef(null), y = t.useRef(!!g), k = X(w), R = X(S), [T, C, O, E] = P(() => { ++ const e = !!c, t = I(z, itemEstimates, g, a, !itemEstimates); return [ t, W(t, e), M(t, e), e ]; }); - z !== T.T() && T.q(5, [ z, l ]), h !== T.O() && T.q(6, h); -+ z !== T.T() && T.q(5, [ z, l, A ]), h !== T.O() && T.q(6, h); ++ const nextKeys = t.useMemo(() => itemKey ? Array.from({length: z}, (_, index) => itemKey(n[index])) : null, [itemKey, n, z]); ++ const previousKeys = t.useRef(nextKeys); ++ const oldKeys = previousKeys.current; ++ if (nextKeys && oldKeys && (nextKeys.length !== oldKeys.length || nextKeys.some((key, index) => key !== oldKeys[index]))) { ++ const prepend = l && nextKeys.length > oldKeys.length && oldKeys.every((key, index) => key === nextKeys[index + nextKeys.length - oldKeys.length]); ++ const append = nextKeys.length >= oldKeys.length && oldKeys.every((key, index) => key === nextKeys[index]); ++ if (!prepend && !append) T.q(10, [oldKeys, nextKeys, itemEstimates]); ++ } ++ previousKeys.current = nextKeys; ++ z !== T.T() && T.q(5, [ z, l, itemEstimates, !!nextKeys ]), h !== T.O() && T.q(6, h); const [H, q] = t.useReducer(T._, void 0, T._), B = T.M(), L = T.h(), j = O.N(), D = [], U = t => { const o = $(t); return e.jsx(Y, { +@@ -594,7 +654,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + }, + children: D + }); +-}), D = /*#__PURE__*/ t.forwardRef(({children: t, data: o, bufferSize: r, itemSize: n, shift: s, horizontal: i, keepMounted: l, cache: c, ssrCount: f, item: u, onScroll: d, onScrollEnd: a, style: h, ...g}, p) => e.jsx("div", { ++}), D = /*#__PURE__*/ t.forwardRef(({children: t, data: o, itemKey, bufferSize: r, itemSize: n, shift: s, horizontal: i, keepMounted: l, cache: c, ssrCount: f, item: u, onScroll: d, onScrollEnd: a, style: h, ...g}, p) => e.jsx("div", { + ...g, + style: { + display: i ? "inline-block" : "block", +@@ -607,6 +667,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + children: e.jsx(j, { + ref: p, + data: o, ++ itemKey, + bufferSize: r, + itemSize: n, + shift: s, diff --git a/lib/index.js b/lib/index.js -index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed7746287e 100644 +index 110ac3858a002a6cdb698da2b56350bc1bf609d2..cef03edd3b9d4438cae5ae7d1ebc3b30425be5af 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,7 @@ "use client"; import { jsx as e } from "react/jsx-runtime"; - + -import { useLayoutEffect as t, useEffect as o, useRef as n, memo as r, useMemo as s, forwardRef as i, useReducer as l, useImperativeHandle as c } from "react"; +import { useLayoutEffect as t, useEffect as o, useRef as n, memo as r, useMemo as s, useMemo as aa, forwardRef as i, useReducer as l, useImperativeHandle as c } from "react"; - + import { flushSync as f } from "react-dom"; - -@@ -39,10 +39,18 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + +@@ -39,19 +39,28 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, b(e, s) <= t ? (r = s, o = s + 1) : n = s - 1; } return p(r, 0, e.l - 1); -}, x = (e, t, o) => { +}, x = (e, t, o, r) => { const n = t - e.l; -- return e.i = o ? -1 : d(t - 1, e.i), e.l = t, n > 0 ? (S(e.u, n), S(e.t, n, o), +- return e.i = o ? -1 : d(t - 1, e.i), e.l = t, n > 0 ? (S(e.u, n), S(e.t, n, o), - e.o * n) : (e.u.splice(n), (o ? e.t.splice(0, -n) : e.t.splice(n)).reduce((t, o) => t - (-1 === o ? e.o : o), 0)); + if (e.i = o ? -1 : d(t - 1, e.i), e.l = t, n > 0) { + S(e.u, n); -+ if (r) { ++ if (Array.isArray(r)) { + const t = o ? r.slice(0, n) : r.slice(r.length - n); + e.t[o ? "unshift" : "push"](...t); + return t.reduce((e, t) => e + t, 0); @@ -122,8 +226,10 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed + return e.u.splice(n), (o ? e.t.splice(0, -n) : e.t.splice(n)).reduce((t, o) => t - (-1 === o ? e.o : o), 0); }, I = "undefined" != typeof window, k = e => e.documentElement, R = e => e.ownerDocument, T = e => e.defaultView, C = /*#__PURE__*/ w(() => !!/iP(hone|od|ad)/.test(navigator.userAgent) || "MacIntel" === navigator.platform && navigator.maxTouchPoints > 0), M = /*#__PURE__*/ w(() => "scrollBehavior" in k(document).style), O = e => a(e.h(), e.p()), E = (e, t = 40, o = 0, n, r = !1) => { let s = !!o, i = 1, l = 0, c = 0, f = 0, g = 0, p = 0, m = 0, _ = 0, w = 0, I = u, k = [ 0, s ? a(o - 1, 0) : -1 ], R = 0, T = !1; ++ let keyedAnchor = 0; const M = ((e, t, o) => ({ -@@ -51,7 +59,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + o: t, + t: o ? S(o.slice(0, d(e, o.length)), a(0, e - o.length)) : S([], e), l: e, i: -1, u: S([], e + 1) @@ -132,37 +238,117 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed if (n = d(n, e.l - 1), b(e, n) <= t) { const r = y(e, o, n); return [ y(e, t, n, r), r ]; -@@ -151,7 +159,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +@@ -92,7 +101,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + p: () => l, + O: () => c, + h: B, +- H: () => (m = g, g = 0, [ m, 2 === w ]), ++ H: () => (m = g, g = 0, [ m, 2 === w || 3 === w ]), + W: (e, t) => { + const o = [ e, t ]; + return O.add(o), () => { +@@ -124,7 +133,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + if (!e.length) break; + N(e.reduce((e, [t, o]) => { + let n; +- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else { ++ if (3 === w) n = t < keyedAnchor; else if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else { + const e = E(), o = J(t), r = A(t); + n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l; + } +@@ -151,7 +160,12 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, break; - + case 5: - t[1] ? (N(x(M, t[0], !0)), w = 2, d = 1) : (x(M, t[0]), d = 1); -+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), w = 0, d = 1); ++ // Keep the exact-prefix cache shift, but keyed lists measure ++ // relative to the reading row, not the entire retained tail. ++ // Prepending can change that row's grouping/height; compensating ++ // its own resize would move its top in the opposite direction. ++ if (t[1] && t[3]) keyedAnchor = y(M, a(0, H())) + t[0] - M.l; ++ t[1] ? (N(x(M, t[0], !0, t[2])), w = t[3] ? 3 : 2, d = 1) : (x(M, t[0], !1, t[2]), w = 0, d = 1); break; - + case 6: -@@ -164,6 +172,10 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, - +@@ -164,6 +178,39 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + case 8: I = W(t, t + l), d = 1; + break; + ++ // Keyed structural update: retain measured sizes by identity, and ++ // move the old visible row (or its next surviving neighbor) by ++ // the exact model delta. The normal jump flush remains the sole ++ // scroll writer; later measurements compensate above this anchor. ++ case 10: { ++ const [previousKeys, nextKeys, estimates] = t; ++ const nextIndex = new Map(nextKeys.map((key, index) => [key, index])); ++ let anchor = y(M, a(0, H())); ++ const originalAnchor = anchor; ++ while (anchor < previousKeys.length && !nextIndex.has(previousKeys[anchor])) anchor++; ++ if (anchor === previousKeys.length) { ++ anchor = originalAnchor - 1; ++ while (anchor >= 0 && !nextIndex.has(previousKeys[anchor])) anchor--; ++ } ++ const oldOffset = anchor >= 0 ? b(M, anchor) : 0; ++ const sizes = new Map(previousKeys.map((key, index) => [key, M.t[index]])); ++ M.t = nextKeys.map((key, index) => sizes.has(key) ? sizes.get(key) : Array.isArray(estimates) ? estimates[index] : -1); ++ M.l = nextKeys.length; ++ M.i = -1; ++ M.u = S([], M.l + 1); ++ keyedAnchor = anchor >= 0 ? nextIndex.get(previousKeys[anchor]) : 0; ++ k = [keyedAnchor, keyedAnchor]; ++ I = u; ++ if (anchor >= 0) N(b(M, keyedAnchor) - oldOffset); ++ w = 3; ++ d = 3; ++ break; ++ } ++ + case 9: + w = 0; } d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => { d & e && t(n); -@@ -190,6 +202,8 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +@@ -189,15 +236,25 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + })(), g = () => { l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h(); }, p = t => { - if (c || !e.M() || t.ctrlKey) return; -+ if (!e.M() || t.ctrlKey) return; -+ e.B(9); ++ if (t.ctrlKey) return; ++ (o ? t.deltaX : t.deltaY) && e.B(9); ++ if (!e.M()) return; + if (c) return; const n = i() - l; 150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0); }, v = () => { -@@ -270,7 +283,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +- f = !0, d = a = !1; ++ e.B(9), f = !0, d = a = !1; + }, m = () => { + f = !1, C() && (d = !0); + }; +- return t.addEventListener("scroll", g), t.addEventListener("wheel", p, { ++ const pointer = event => { if (event.target === t) e.B(9); }; ++ const key = event => { ++ if (event.ctrlKey || event.metaKey || event.altKey || ++ event.target?.closest?.("input,textarea,select,[contenteditable='true']")) return; ++ if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) e.B(9); ++ }; ++ return t.addEventListener("pointerdown", pointer, { passive: !0 }), ++ t.addEventListener("keydown", key), t.addEventListener("scroll", g), t.addEventListener("wheel", p, { + passive: !0 + }), t.addEventListener("touchstart", v, { + passive: !0 +@@ -206,7 +263,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + }), { + A: () => { + t.removeEventListener("scroll", g), t.removeEventListener("wheel", p), t.removeEventListener("touchstart", v), +- t.removeEventListener("touchend", m), h.J(); ++ t.removeEventListener("touchend", m), h.J(), t.removeEventListener("pointerdown", pointer), t.removeEventListener("keydown", key); + }, + L: () => { + const [t, o] = e.H(); +@@ -270,7 +327,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, e[l] = t; }); } @@ -171,26 +357,77 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed }), r[1](!0); }, v() { -@@ -523,11 +536,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +@@ -522,12 +579,21 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + return D(e, t), t; })(e); return [ e => o[e], o.length ]; - }, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => { +-}, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => { - const [b, y] = Q(t, o), x = n(null), I = n(!!g), k = F(S), R = F($), [T, C, M, H] = U(() => { - const e = !!u, t = E(y, s, g, a, !s); ++}, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, itemKey, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => { + const [b, y] = Q(t, o), bb = aa(() => "function" == typeof s ? Array.from({length: y}, (e, t) => s(o[t], t)) : s, [ s, o, y ]), x = n(null), I = n(!!g), k = F(S), R = F($), [T, C, M, H] = U(() => { + const e = !!u, t = E(y, bb, g, a, !bb); return [ t, V(t, e), A(t, e), e ]; }); - y !== T.T() && T.B(5, [ y, i ]), h !== T.O() && T.B(6, h); -+ y !== T.T() && T.B(5, [ y, i, bb ]), h !== T.O() && T.B(6, h); ++ const nextKeys = aa(() => itemKey ? Array.from({length: y}, (_, index) => itemKey(o[index])) : null, [itemKey, o, y]); ++ const previousKeys = n(nextKeys); ++ const oldKeys = previousKeys.current; ++ if (nextKeys && oldKeys && (nextKeys.length !== oldKeys.length || nextKeys.some((key, index) => key !== oldKeys[index]))) { ++ const prepend = i && nextKeys.length > oldKeys.length && oldKeys.every((key, index) => key === nextKeys[index + nextKeys.length - oldKeys.length]); ++ const append = nextKeys.length >= oldKeys.length && oldKeys.every((key, index) => key === nextKeys[index]); ++ if (!prepend && !append) T.B(10, [oldKeys, nextKeys, bb]); ++ } ++ previousKeys.current = nextKeys; ++ y !== T.T() && T.B(5, [ y, i, bb, !!nextKeys ]), h !== T.O() && T.B(6, h); const [W, B] = l(T.m, void 0, T.m), J = T.M(), L = T.h(), N = M.P(), P = [], X = t => { const o = b(t); return e(K, { +@@ -598,7 +664,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + }, + children: P + }); +-}), ee = /*#__PURE__*/ i(({children: t, data: o, bufferSize: n, itemSize: r, shift: s, horizontal: i, keepMounted: l, cache: c, ssrCount: f, item: u, onScroll: d, onScrollEnd: a, style: h, ...g}, p) => e("div", { ++}), ee = /*#__PURE__*/ i(({children: t, data: o, itemKey, bufferSize: n, itemSize: r, shift: s, horizontal: i, keepMounted: l, cache: c, ssrCount: f, item: u, onScroll: d, onScrollEnd: a, style: h, ...g}, p) => e("div", { + ...g, + style: { + display: i ? "inline-block" : "block", +@@ -611,6 +677,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + children: e(Z, { + ref: p, + data: o, ++ itemKey, + bufferSize: n, + itemSize: r, + shift: s, +diff --git a/lib/react/VList.d.ts b/lib/react/VList.d.ts +index 2cfcb913585ba21863174672d4971b467a4260a5..84fdcc3bd79551e17276cd0ed3b838dc89914fb7 100644 +--- a/lib/react/VList.d.ts ++++ b/lib/react/VList.d.ts +@@ -9,7 +9,7 @@ export interface VListHandle extends VirtualizerHandle { + /** + * Props of {@link VList}. + */ +-export interface VListProps extends Pick, "children" | "data" | "bufferSize" | "itemSize" | "shift" | "horizontal" | "cache" | "ssrCount" | "item" | "onScroll" | "onScrollEnd" | "keepMounted">, ViewportComponentAttributes { ++export interface VListProps extends Pick, "children" | "data" | "itemKey" | "bufferSize" | "itemSize" | "shift" | "horizontal" | "cache" | "ssrCount" | "item" | "onScroll" | "onScrollEnd" | "keepMounted">, ViewportComponentAttributes { + } + /** + * Virtualized list component. See {@link VListProps} and {@link VListHandle}. diff --git a/lib/react/Virtualizer.d.ts b/lib/react/Virtualizer.d.ts -index 46c5d0765641bb1b4afeb9782b64e5fd71e6a1c8..e82091f3514fdccf064bf28c0a1b22c8615fbdf2 100644 +index 46c5d0765641bb1b4afeb9782b64e5fd71e6a1c8..2a8643b8f6188cd8bd86ec0f66a47309746b91ce 100644 --- a/lib/react/Virtualizer.d.ts +++ b/lib/react/Virtualizer.d.ts -@@ -78,7 +78,7 @@ export interface VirtualizerProps { +@@ -67,6 +67,9 @@ export interface VirtualizerProps { + * The data items rendered by this component. If you set a function to {@link VirtualizerProps.children}, you have to set this prop. + */ + data?: ArrayLike; ++ /** Stable row identity. Reconciles measured sizes and the visible anchor ++ * for mixed insertions/deletions; omitted retains upstream positional mode. */ ++ itemKey?: (data: T) => string; + /** + * Extra item space in pixels to render before/after the viewport. The minimum value is 0. Lower value will give better performance but you can increase to avoid showing blank items in fast scrolling. + * @defaultValue 200 +@@ -78,7 +81,7 @@ export interface VirtualizerProps { * - If not set, initial item sizes will be automatically estimated from measured sizes. This is recommended for most cases. * - If set, you can opt out estimation and use the value as initial item size. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95612ae1fd2..401ae31d7f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ overrides: patchedDependencies: isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f - virtua@0.49.3: 30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4 + virtua@0.49.3: bb229dc918031a08fb89fa6563d0d0fda9d1819ec7585f360b46b0abc68e9b0b importers: @@ -242,7 +242,7 @@ importers: version: 2.1.0 virtua: specifier: 0.49.3 - version: 0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.49.3(patch_hash=bb229dc918031a08fb89fa6563d0d0fda9d1819ec7585f360b46b0abc68e9b0b)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yaml: specifier: ^2.8.3 version: 2.9.0 @@ -7009,7 +7009,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + virtua@0.49.3(patch_hash=bb229dc918031a08fb89fa6563d0d0fda9d1819ec7585f360b46b0abc68e9b0b)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) From 4c25af1660fa7890cee8340f201ecab26ba8f18f Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 16:24:38 -0600 Subject: [PATCH 2/4] fix(desktop): preserve history through recovery cancellation and live updates Scope explicit Load latest intent to one fetch and its retries, retiring unclaimed intent after cancellation. Update the authoritative window on accepted deletion without erasing concurrent history or live arrivals. Admit backdated live rows in exhausted windows and reconnect gap rows before the buffered tail. Add real-hook and browser regressions; give the huddle copy-link fixture its own signed-event id. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- .../features/communities/useCommunityInit.ts | 2 + desktop/src/features/messages/hooks.ts | 31 +- .../lib/channelWindowRefreshIntent.ts | 29 ++ .../messages/lib/channelWindowStore.test.mjs | 36 ++- .../messages/lib/channelWindowStore.ts | 32 +- .../messages/ui/HistoryRefreshNotice.test.mjs | 297 ++++++++++++++++++ .../messages/ui/HistoryRefreshNotice.tsx | 18 +- .../ui/historyPaginationLifecycle.test.mjs | 105 +++++++ .../ui/useBufferedTimelineMessages.test.mjs | 11 + .../ui/useBufferedTimelineMessages.ts | 24 +- desktop/tests/e2e/message-copy-link.spec.ts | 4 +- desktop/tests/e2e/relay-reconnect.spec.ts | 47 +++ 12 files changed, 590 insertions(+), 46 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelWindowRefreshIntent.ts create mode 100644 desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b4..e221c72db94 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,3 +1,4 @@ +import { resetChannelWindowRefreshIntents } from "@/features/messages/lib/channelWindowRefreshIntent"; import { useEffect, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { isMacPlatform } from "@/shared/lib/platform"; @@ -59,6 +60,7 @@ async function resetCommunityState({ relayClient.disconnect(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); + resetChannelWindowRefreshIntents(); clearAllDrafts(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index f4b30e6eb55..9c0e59a5b23 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,8 +1,5 @@ -import { - appendOlderChannelWindow, - type ChannelWindowPage, -} from "./lib/channelWindowStore"; import { revalidateChannelWindow } from "./lib/revalidateChannelWindow"; +import { consumeChannelWindowRefreshIntent } from "./lib/channelWindowRefreshIntent"; import { useEffect, useEffectEvent } from "react"; import { type QueryClient, @@ -66,6 +63,8 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { + appendOlderChannelWindow, + type ChannelWindowPage, emptyChannelWindowStore, mapChannelWindowEvents, mergeLiveChannelWindowEvent, @@ -314,11 +313,17 @@ export function useChannelMessagesQuery(channel: Channel | null) { queryKey, queryFn: async ({ signal }) => { if (!channel) throw new Error("No channel selected."); + const latestOnly = consumeChannelWindowRefreshIntent( + queryClient, + channel.id, + signal, + ); // Persisted heads seed asynchronously; wait for that seed so a channel // opened during boot takes the hydrated path instead of racing it with // a cold relay fetch. await channelHeadHydration(queryClient); - if (consumeHydratedChannel(queryClient, channel.id)) { + signal.throwIfAborted(); + if (consumeHydratedChannel(queryClient, channel.id) && !latestOnly) { return queryClient.getQueryData(queryKey) ?? []; } const previousMessages = @@ -331,11 +336,9 @@ export function useChannelMessagesQuery(channel: Channel | null) { const events = await getChannelWindowEvents(channel.id); return await revalidateChannelWindow({ head: parseChannelWindowResponse(events, channel.id, null), - retained: retained.refreshLatestOnly - ? emptyChannelWindowStore() - : retained, + retained: latestOnly ? emptyChannelWindowStore() : retained, readCurrent: () => - retained.refreshLatestOnly + latestOnly ? emptyChannelWindowStore() : (queryClient.getQueryData( channelWindowKey(channel.id), @@ -365,7 +368,6 @@ export function useChannelMessagesQuery(channel: Channel | null) { channelWindowKey(channel.id), (current) => ({ ...(current ?? retained), - refreshLatestOnly: false, refreshError: (current ?? retained).pages.length ? "Couldn’t refresh messages. Your loaded history is still available." : "Couldn’t load messages. Try again.", @@ -882,6 +884,15 @@ export function useDeleteMessageMutation(channel: Channel | null) { }, onSuccess: (_data, { eventId }) => { if (!channel) return; + queryClient.setQueryData( + channelWindowKey(channel.id), + (current) => + current + ? mapChannelWindowEvents(current, (event) => + event.id === eventId ? null : event, + ) + : current, + ); queryClient.setQueryData( channelMessagesKey(channel.id), (current = []) => current.filter((message) => message.id !== eventId), diff --git a/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts b/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts new file mode 100644 index 00000000000..002634aa160 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts @@ -0,0 +1,29 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { ChannelWindowStore } from "./channelWindowStore"; +import { channelWindowKey } from "./messageQueryKeys"; + +// TanStack reuses one AbortSignal across a fetch's retry attempts, but creates +// a new signal for every later fetch. Never leave destructive intent in the +// durable window after the request has claimed it. +let latestRequests = new WeakSet(); + +/** Retire request intent along with the other community-scoped state. */ +export function resetChannelWindowRefreshIntents() { + latestRequests = new WeakSet(); +} + +/** Claim queued latest-only intent once, retaining it only for this fetch’s retries. */ +export function consumeChannelWindowRefreshIntent( + client: QueryClient, + channelId: string, + signal: AbortSignal, +) { + signal.throwIfAborted(); + const key = channelWindowKey(channelId); + const current = client.getQueryData(key); + if (current?.refreshLatestOnly) { + latestRequests.add(signal); + client.setQueryData(key, { ...current, refreshLatestOnly: undefined }); + } + return latestRequests.has(signal); +} diff --git a/desktop/src/features/messages/lib/channelWindowStore.test.mjs b/desktop/src/features/messages/lib/channelWindowStore.test.mjs index b9ccae0db70..9c4f64506da 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.test.mjs +++ b/desktop/src/features/messages/lib/channelWindowStore.test.mjs @@ -177,13 +177,18 @@ test("same-second live rows enter an exhausted short window regardless of id ord ); }); -test("older live rows stay below an exhausted window", () => { +test("older live rows enter an exhausted window because there is no unretained gap", () => { const store = replaceNewestChannelWindow( emptyChannelWindowStore(), page(null, [event("a", 100)], { hasMore: false }), ); - assert.equal(mergeLiveChannelWindowEvent(store, event("old", 99)), store); + const withLive = mergeLiveChannelWindowEvent(store, event("old", 99)); + assert.deepEqual( + flattenChannelWindowEvents(withLive).map((e) => e.content), + ["old", "a"], + ); + assert.equal(withLive.revision, store.revision); }); test("live aux stays separate from authoritative page closure", () => { @@ -450,3 +455,30 @@ test("exhaustion tracks only a resolved tail page's hasMore", () => { assert.equal(channelWindowHistoryExhausted(closed), true); assert.equal(channelWindowHasMore(closed), false); }); + +test("local removal updates page rows and overlays without moving cursor boundaries", () => { + let store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("head", 100), event("boundary", 90)], { + aux: [event("page-aux", 95, 7)], + }), + ); + store = mergeLiveChannelWindowEvent(store, event("live", 110)); + store = mergeLiveChannelWindowEvent(store, event("live-aux", 105, 7), false); + const before = store; + store = mapChannelWindowEvents(store, (event) => + event.content === "head" ? event : null, + ); + assert.deepEqual( + flattenChannelWindowEvents(store).map((e) => e.content), + ["head"], + ); + assert.equal(store.pages[0].startCursor, before.pages[0].startCursor); + assert.equal(store.pages[0].nextCursor, before.pages[0].nextCursor); + assert.equal(store.pages[0].hasMore, before.pages[0].hasMore); + assert.equal(store.revision, before.revision); + assert.equal( + mapChannelWindowEvents(store, (e) => e), + store, + ); +}); diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 70ee1a887ae..49c9d731a5a 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -31,8 +31,8 @@ export type ChannelWindowStore = { pages: ChannelWindowPage[]; /** A recoverable head refresh failure; retained history remains readable. */ refreshError?: string; - /** Explicit recovery request; retire retained history only after a new head succeeds. */ - refreshLatestOnly?: boolean; + /** Unclaimed explicit recovery token; consumed at fetch start, never by later refreshes. */ + refreshLatestOnly?: string; /** Top-level live events not represented in an authoritative relay page. */ liveOverlay: RelayEvent[]; /** Live structural events retained independently from frozen page closure. */ @@ -218,11 +218,7 @@ export function mergeLiveChannelWindowEvent( } const oldestPage = current.pages[current.pages.length - 1]; const oldest = oldestPage?.rows[oldestPage.rows.length - 1]?.event; - if ( - oldest && - (event.created_at < oldest.created_at || - (oldestPage.hasMore && compareRelayOrder(event, oldest) >= 0)) - ) { + if (oldest && oldestPage.hasMore && compareRelayOrder(event, oldest) >= 0) { return current; } return { @@ -237,7 +233,7 @@ export function mergeLiveChannelWindowEvent( /** * Apply a per-event transform across every event the store holds (page rows, * page aux, live overlay, live aux), returning the same store reference when - * nothing changed. + * nothing changed. Return null to remove an event without changing page cursors. * * Local writes MUST go through this rather than patching the flattened * `channelMessagesKey` array alone: the window store is the source of truth, @@ -251,7 +247,7 @@ export function mergeLiveChannelWindowEvent( */ export function mapChannelWindowEvents( store: ChannelWindowStore, - map: (event: RelayEvent) => RelayEvent, + map: (event: RelayEvent) => RelayEvent | null, ): ChannelWindowStore { let changed = false; const mapEvent = (event: RelayEvent) => { @@ -260,18 +256,24 @@ export function mapChannelWindowEvents( return next; }; const pages = store.pages.map((page) => { - const rows = page.rows.map((row) => { + const rows = page.rows.flatMap((row) => { const event = mapEvent(row.event); - return event === row.event ? row : { ...row, event }; + return event === null + ? [] + : [event === row.event ? row : { ...row, event }]; }); - const aux = page.aux.map(mapEvent); - return rows.every((row, index) => row === page.rows[index]) && + const aux = page.aux.map(mapEvent).filter((event) => event !== null); + return rows.length === page.rows.length && + rows.every((row, index) => row === page.rows[index]) && + aux.length === page.aux.length && aux.every((event, index) => event === page.aux[index]) ? page : { ...page, rows, aux }; }); - const liveOverlay = store.liveOverlay.map(mapEvent); - const liveAux = store.liveAux.map(mapEvent); + const liveOverlay = store.liveOverlay + .map(mapEvent) + .filter((event) => event !== null); + const liveAux = store.liveAux.map(mapEvent).filter((event) => event !== null); return changed ? { ...store, pages, liveOverlay, liveAux } : store; } diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs b/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs new file mode 100644 index 00000000000..6800b8eb59e --- /dev/null +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs @@ -0,0 +1,297 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useChannelMessagesQuery, useSendMessageMutation } from "../hooks.ts"; +import { HistoryRefreshNotice } from "./HistoryRefreshNotice.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; +import { + channelMessagesKey, + channelWindowKey, +} from "../lib/messageQueryKeys.ts"; +import { parseChannelWindowResponse } from "../lib/channelWindowResponse.ts"; +import { + appendOlderChannelWindow, + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "../lib/channelWindowStore.ts"; +import { + projectChannelWindowMessages, + refreshChannelWindowMessages, +} from "../lib/projectChannelWindow.ts"; + +const channel = { id: "channel", channelType: "stream" }; +const event = (index) => ({ + id: index.toString(16).padStart(64, "0"), + pubkey: "a".repeat(64), + created_at: 5000 - index, + kind: 9, + tags: [["h", channel.id]], + content: `row ${index}`, + sig: "", +}); +const server = Array.from({ length: 150 }, (_, i) => event(i)); +function wirePage(cursor, limit = 50) { + const start = cursor + ? server.findIndex((e) => e.id === cursor.event_id) + 1 + : 0; + const rows = server.slice(start, start + limit); + const more = start + rows.length < server.length; + const last = rows.at(-1); + return [ + ...rows, + { + ...event(999), + kind: 39006, + tags: [ + [ + "d", + cursor + ? `channel:${cursor.created_at}:${cursor.event_id}` + : "channel:head", + ], + ], + content: JSON.stringify({ + has_more: more, + next_cursor: more ? { created_at: last.created_at, id: last.id } : null, + }), + }, + ]; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +async function until(predicate) { + for (let i = 0; i < 100 && !predicate(); i++) await act(tick); + assert.ok(predicate(), "expected async state to settle"); +} +async function setup(t, retry = false) { + const dom = new JSDOM("
", { url: "http://localhost" }); + const globals = { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Node: dom.window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }; + const saved = Object.fromEntries( + Object.keys(globals).map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key), + ]), + ); + for (const [key, value] of Object.entries(globals)) + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + const client = new QueryClient({ + defaultOptions: { + queries: { retry, retryDelay: 0, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + let store = emptyChannelWindowStore(); + for (let i = 0; i < 3; i++) { + const cursor = store.pages.at(-1)?.nextCursor ?? null; + const wireCursor = cursor + ? { created_at: cursor.createdAt, event_id: cursor.eventId } + : null; + const page = parseChannelWindowResponse( + wirePage(wireCursor), + channel.id, + cursor, + ); + store = i + ? appendOlderChannelWindow(store, page) + : replaceNewestChannelWindow(store, page); + } + client.setQueryData(channelWindowKey(channel.id), { + ...store, + refreshError: + "Couldn’t refresh messages. Your loaded history is still available.", + }); + projectChannelWindowMessages(client, channel.id); + const requests = []; + let respond = async () => {}; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(command, "get_channel_window"); + requests.push(args.cursor ? "page" : "head"); + const response = wirePage(args.cursor, args.limitRows); + await respond(args); + return response; + }, + }; + t.mock.method(relayClient, "sendMessage", async () => ({ + ...event(1000), + created_at: 6000, + content: "sent while refreshing", + })); + let mutation; + let navigations = 0; + function Screen({ active }) { + useChannelMessagesQuery(active ? channel : null); + mutation = useSendMessageMutation(channel, { pubkey: "a".repeat(64) }); + return React.createElement(HistoryRefreshNotice, { + channelId: active ? channel.id : null, + onLoadLatest: () => navigations++, + }); + } + const root = createRoot(document.getElementById("root")); + const render = async (active = true) => + act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Screen, { active }), + ), + ), + ); + await render(); + t.after(async () => { + await act(async () => { + root.unmount(); + client.clear(); + await tick(); + }); + dom.window.close(); + for (const [key, descriptor] of Object.entries(saved)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const read = () => client.getQueryData(channelWindowKey(channel.id)); + return { + client, + requests, + read, + render, + navigations: () => navigations, + respond: (fn) => { + respond = fn; + }, + send: () => + act(async () => { + await mutation.mutateAsync({ content: "sent while refreshing" }); + }), + click: async (label = "Load latest") => { + const button = [...document.querySelectorAll("button")].find( + (b) => b.textContent === label, + ); + assert.ok(button); + assert.equal(button.disabled, false); + await act(async () => button.click()); + }, + refresh: () => + act(async () => { + await refreshChannelWindowMessages(client, channel.id); + }), + idle: () => + until( + () => + client.isFetching({ queryKey: channelMessagesKey(channel.id) }) === 0, + ), + rows: () => read().pages.reduce((n, page) => n + page.rows.length, 0), + }; +} + +for (const cancel of ["send", "navigation"]) { + test(`Load latest canceled by ${cancel} cannot discard history on a later implicit refresh`, async (t) => { + const h = await setup(t); + let release; + h.respond( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await h.click(); + await until(() => !!release); + if (cancel === "send") await h.send(); + else await h.render(false); + await h.idle(); + assert.equal(h.rows(), 150); + await act(async () => { + release(); + await tick(); + }); + h.respond(async () => {}); + if (cancel === "navigation") await h.render(); + await h.refresh(); + assert.equal( + h.rows(), + 150, + "unrelated refresh must not spend canceled navigation intent", + ); + assert.equal(h.navigations(), 0); + }); +} + +test("successful Load latest replaces retained pages even when the head is unchanged", async (t) => { + const h = await setup(t); + await h.click(); + await h.idle(); + assert.equal(h.rows(), 50); + assert.equal(h.read().pages.length, 1); + assert.equal(h.read().refreshError, undefined); + assert.equal(h.navigations(), 1); + assert.deepEqual(h.requests, ["head"]); +}); + +test("Load latest keeps its intent across real TanStack retry: 1 attempts", async (t) => { + const h = await setup(t, 1); + let attempts = 0; + h.respond(async () => { + if (++attempts === 1) throw Error("first attempt failed"); + }); + await h.click(); + await h.idle(); + assert.equal(h.rows(), 50); + assert.equal(h.read().pages.length, 1); + assert.equal(h.navigations(), 1); + assert.deepEqual(h.requests, ["head", "head"]); +}); + +for (const label of ["Retry", "Load latest"]) { + test(`${label} exhausted retries preserve history and never navigate`, async (t) => { + const h = await setup(t, 1); + h.respond(async () => { + throw Error("offline"); + }); + await h.click(label); + await h.idle(); + assert.equal(h.rows(), 150); + assert.ok(h.read().refreshError); + assert.equal(h.navigations(), 0); + assert.deepEqual(h.requests, ["head", "head"]); + h.respond(async () => {}); + await h.refresh(); + assert.equal(h.rows(), 150); + assert.equal(h.navigations(), 0); + }); +} + +test("unmounting before invalidation starts cannot leave an unclaimed Load latest token", async (t) => { + const h = await setup(t); + const originalInvalidate = h.client.invalidateQueries.bind(h.client); + let release; + t.mock.method(h.client, "invalidateQueries", async (...args) => { + await new Promise((resolve) => { + release = resolve; + }); + return originalInvalidate(...args); + }); + await h.click(); + await until(() => !!release); + await h.render(false); + await act(async () => { + release(); + await tick(); + }); + assert.equal(h.read().refreshLatestOnly, undefined); + assert.equal(h.navigations(), 0); + assert.deepEqual(h.requests, []); +}); diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx index 7cd304c3546..201a5405f3b 100644 --- a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx @@ -41,14 +41,28 @@ export function HistoryRefreshNotice({ }, [scope]); if (!channelId || !window?.refreshError) return null; const retry = async (latest: boolean) => { + const token = latest ? crypto.randomUUID() : undefined; client.setQueryData( channelWindowKey(channelId), (current) => ({ ...(current ?? emptyChannelWindowStore()), - refreshLatestOnly: latest, + refreshLatestOnly: token, }), ); - await refreshChannelWindowMessages(client, channelId); + try { + await refreshChannelWindowMessages(client, channelId); + } finally { + // The observer can unmount before hydration/invalidation starts a fetch. + // Retire an unclaimed token too, without clearing a newer button click. + if (token) + client.setQueryData( + channelWindowKey(channelId), + (current) => + current?.refreshLatestOnly === token + ? { ...current, refreshLatestOnly: undefined } + : current, + ); + } const refreshed = client.getQueryData( channelWindowKey(channelId), ); diff --git a/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs index e51db2f2cbf..d2b6a8363f3 100644 --- a/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs +++ b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs @@ -826,3 +826,108 @@ test("failed send removes only its optimistic row, retaining concurrent history ["older", "head", "live", "other-pending"], ); }); + +const { useDeleteMessageMutation } = await import("../hooks.ts"); + +test("accepted deletion changes the authoritative window even without a live deletion echo", async (t) => { + const env = await setup(t); + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + t.after(() => client.clear()); + let acceptDelete; + let deleteStarted; + const started = new Promise((resolve) => { + deleteStarted = resolve; + }); + window.__TAURI_INTERNALS__ = { + invoke: async (command) => { + assert.equal(command, "delete_message"); + deleteStarted(); + await new Promise((resolve) => { + acceptDelete = resolve; + }); + }, + }; + const event = (id, created_at) => ({ + id, + created_at, + kind: 9, + pubkey: "p", + content: id, + tags: [["h", "a"]], + sig: "", + }); + const target = event("target", 100); + const key = channelWindowKey("a"); + client.setQueryData( + key, + replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: target, thread: null }], + aux: [], + nextCursor: { createdAt: 100, eventId: target.id }, + hasMore: true, + }), + ); + projectChannelWindowMessages(client, "a"); + let mutation; + function Harness() { + mutation = useDeleteMessageMutation({ id: "a", channelType: "stream" }); + return null; + } + await act(async () => + env.root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Harness), + ), + ), + ); + let result; + await act(async () => { + result = mutation.mutateAsync({ eventId: target.id }); + await started; + }); + const before = client.getQueryData(key); + let current = appendOlderChannelWindow(before, { + startCursor: before.pages[0].nextCursor, + rows: [{ event: event("older", 90), thread: null }], + aux: [], + nextCursor: null, + hasMore: false, + }); + current = mergeLiveChannelWindowEvent(current, event("concurrent-live", 110)); + client.setQueryData(key, current); + projectChannelWindowMessages(client, "a"); + await act(async () => { + acceptDelete(); + await result; + }); + const after = client.getQueryData(key); + assert.equal( + after.pages.length, + 2, + "deletion retains a concurrent older page", + ); + assert.equal(after.revision, current.revision); + assert.equal(after.pages[0].rows.length, 0); + assert.equal( + after.pages[0].nextCursor.eventId, + target.id, + "removing a boundary row must not invalidate its cursor", + ); + client.setQueryData( + key, + mergeLiveChannelWindowEvent(after, event("live", 120)), + ); + projectChannelWindowMessages(client, "a"); + assert.deepEqual( + client.getQueryData(channelMessagesKey("a")).map((e) => e.id), + ["older", "concurrent-live", "live"], + ); +}); diff --git a/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs index 0d581506ae3..9da3bfa2ba1 100644 --- a/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs +++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs @@ -50,3 +50,14 @@ test("accepts an authoritative replacement when its old tail disappeared", () => messages, ); }); + +test("admits reconnect gap rows inside the frozen window, buffering only rows after its tail", () => { + assert.deepEqual( + selectBufferedTimelineMessages({ + frozenMessageIds: ["old-live", "newer", "tail"], + isAtBottom: false, + messages: rows("old-live", "gap-1", "gap-2", "newer", "tail", "live"), + }).map((e) => e.id), + ["old-live", "gap-1", "gap-2", "newer", "tail"], + ); +}); diff --git a/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts index 46b2a5a577e..cd1cd06f430 100644 --- a/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts +++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts @@ -27,23 +27,15 @@ export function selectBufferedTimelineMessages({ return messages; } - const firstFrozenIndex = messages.findIndex( - (message) => message.id === frozenMessageIds[0], + // Freeze a tail boundary, not membership. A reconnect can fill a gap + // between retained rows (including an old live overlay); those are history, + // not newer output, and must not vanish behind the live-message buffer. + const tailIndex = messages.findIndex( + (message) => message.id === frozenMessageIds[frozenMessageIds.length - 1], ); - const prepended = messages.slice(0, firstFrozenIndex); - const frozen = frozenMessageIds.map((id) => currentById.get(id) as T); - const buffered = [...prepended, ...frozen]; - if ( - buffered.length === messages.length && - buffered.every((message, index) => message.id === messages[index]?.id) - ) { - // Crossing the bottom threshold without a live arrival must be a semantic - // no-op for Virtua. Preserve the source array identity until there is - // actually something to buffer; otherwise the threshold transition can - // rebuild its model while a prepend is starting. - return messages; - } - return buffered; + return tailIndex === messages.length - 1 + ? messages + : messages.slice(0, tailIndex + 1); } export function useBufferedTimelineMessages({ diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts index b2e1b0f5a23..248ed9bf0b6 100644 --- a/desktop/tests/e2e/message-copy-link.spec.ts +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -185,7 +185,9 @@ test("pending and huddle rows omit both copy-link surfaces", async ({ content: JSON.stringify({ ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", }), - id: "d".repeat(64), + // "d".repeat(64) is already the seeded custom-emoji reaction target. + // A live event cannot change the kind of an existing signed event id. + id: "d1".repeat(32), kind: huddleKind, }); return { huddleId: huddle.id, pendingId: pending.id }; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 7868e577dab..c1f90bfe9b8 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -654,6 +654,53 @@ test("auth-required CLOSED restores the active live subscription", async ({ .toBe(true); }); +for (const age of [0, 300]) { + test(`fresh exhausted channel admits a live event backdated ${age}s without a reader gesture`, async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 9, + }), + ), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate(() => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + getQueryData: ( + key: string[], + ) => { pages: Array<{ hasMore: boolean }> } | undefined; + isFetching: (filters: { queryKey: string[] }) => number; + }; + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + return ( + client.getQueryData(["channel-window", channelId])?.pages.at(-1) + ?.hasMore === false && + client.isFetching({ queryKey: ["channel-messages", channelId] }) === + 0 + ); + }), + ) + .toBe(true); + + // No reconnect or refresh can mask admission: this is a single live + // delivery into a fully retained window, including below its oldest row. + const content = `fresh live delivery backdated ${age}s`; + await emitMockMessages(page, [ + { content, createdAt: Math.floor(Date.now() / 1_000) - age }, + ]); + await expect(page.getByTestId("message-timeline")).toContainText(content); + }); +} + test("reconnect backfills more missed channel messages than the live subscription limit", async ({ page, }) => { From 318bfe31c7a8729b5974c0f995a6bd8b20915bea Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 17:43:33 -0600 Subject: [PATCH 3/4] test(desktop): measure reconnect middle-insertion anchoring Retain five history pages and insert a missed row inside the first refreshed join page above the reader. Assert the exact ID sequence, fetched-page membership, mounted row, and frame-by-frame viewport stability. Attach the measured indices and drift to the browser report. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- .../tests/e2e/history-transactions.spec.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/desktop/tests/e2e/history-transactions.spec.ts b/desktop/tests/e2e/history-transactions.spec.ts index b82a948dd27..57036028393 100644 --- a/desktop/tests/e2e/history-transactions.spec.ts +++ b/desktop/tests/e2e/history-transactions.spec.ts @@ -1,4 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; import { expect, test, type Page } from "@playwright/test"; +import type { ChannelWindowStore } from "../../src/features/messages/lib/channelWindowStore"; +import type { RelayEvent } from "../../src/shared/api/types"; import { installMockBridge } from "../helpers/bridge"; import { pageOlderHistory, @@ -151,6 +154,148 @@ for (const race of [false, true]) { }); } +test("reconnect inserts a middle history row above a deep reader without shifting", async ({ + page, +}, testInfo) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-deep-history").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + for (let step = 0; step < 4; step++) await pageOlderHistory(page); + + // Retain five pages, then read inside the first revalidated join page. Deeper + // exact joins deliberately reuse immutable history; this is a reconnect gap + // in the range actually fetched, not a request for arbitrary backfill repair. + await timeline.evaluate((element) => { + element.scrollTop = element.scrollHeight * 0.66; + }); + const readingRow = timeline.locator( + '[data-message-id="mock-deep-history-520"]', + ); + await expect(readingRow).toBeAttached(); + await readingRow.evaluate((row) => { + const scroller = row.closest('[data-testid="message-timeline"]'); + if (!scroller) throw Error("No timeline scroller"); + scroller.scrollTop += + row.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + 100; + }); + await waitForHistorySettled(page); + const anchor = await visibleAnchor(page); + const channelId = "feedf00d-0000-4000-8000-000000000007"; + const before = await page.evaluate( + ({ channelId, anchorId }) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as QueryClient; + const store = client.getQueryData([ + "channel-window", + channelId, + ]); + const rows = client.getQueryData([ + "channel-messages", + channelId, + ]); + if (!store || !rows) throw Error("Missing retained history"); + const anchorIndex = rows.findIndex((row) => row.id === anchorId); + const olderNeighbor = rows[anchorIndex - 5]; + const newerNeighbor = rows[anchorIndex - 4]; + if (!olderNeighbor || !newerNeighbor) throw Error("No interior gap"); + return { + ids: rows.map((row) => row.id), + pages: store.pages.length, + anchorIndex, + gapIndex: anchorIndex - 4, + createdAt: Math.floor( + (olderNeighbor.created_at + newerNeighbor.created_at) / 2, + ), + verifiedPageIds: store.pages[1].rows.map((row) => row.event.id), + }; + }, + { channelId, anchorId: anchor.id }, + ); + expect(before.pages).toBe(5); + expect(before.ids.length - before.anchorIndex).toBeGreaterThan(75); + expect(before.gapIndex).toBeGreaterThan(0); + expect(before.verifiedPageIds).toContain(before.ids[before.gapIndex]); + await beginAnchorTrace(page, anchor.id); + const gap = await page.evaluate((createdAt) => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 150 }, + }; + // Clear the mock sockets synchronously before emitting, so this row cannot + // reach the UI through live delivery. The reconnect must fetch it. + window.__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?.(); + if ( + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "deep-history", + }) + ) + throw Error("Expected disconnected mock subscription"); + const event = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "deep-history", + content: "Recovered middle row above the reader\nwith a second line", + createdAt, + }); + if (!event) throw Error("Missing injected gap event"); + return event; + }, before.createdAt); + await expect + .poll(() => + page.evaluate( + ({ channelId, gapId }) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as QueryClient; + return ( + client + .getQueryData(["channel-window", channelId]) + ?.pages.some((page) => + page.rows.some((row) => row.event.id === gapId), + ) && + client.isFetching({ queryKey: ["channel-messages", channelId] }) === + 0 + ); + }, + { channelId, gapId: gap.id }, + ), + ) + .toBe(true); + await expect( + timeline.locator(`[data-message-id="${gap.id}"]`), + ).toBeAttached(); + await waitForHistorySettled(page); + const after = await page.evaluate((channelId) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as QueryClient; + return client + .getQueryData(["channel-messages", channelId]) + ?.map((row) => row.id); + }, channelId); + const expected = before.ids.toSpliced(before.gapIndex, 0, gap.id); + expect(after).toEqual(expected); // unchanged first/last IDs; not a prepend + const gapTop = await anchorTop(page, gap.id); + expect(gapTop).not.toBeNull(); + expect(gapTop ?? Infinity).toBeLessThan(anchor.top); + await assertAnchorTrace(page, anchor.top); + const trace = await page.evaluate( + () => + (window as unknown as { __HISTORY_ANCHOR_TRACE__: number[] }) + .__HISTORY_ANCHOR_TRACE__, + ); + await testInfo.attach("middle-insertion-anchor.json", { + body: JSON.stringify({ + retainedPages: before.pages, + retainedRows: before.ids.length, + insertionIndex: before.gapIndex, + anchorIndex: before.anchorIndex, + anchor, + gapTop, + samples: trace.length, + maximumDrift: Math.max(...trace.map((top) => Math.abs(top - anchor.top))), + }), + contentType: "application/json", + }); +}); + test("DM exhaustion adds intro and date markers without displacing the reading row", async ({ page, }) => { From 55be15c74c6155af4f6def93ff3f27f19973747d Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 29 Aug 2026 09:30:16 -0600 Subject: [PATCH 4/4] fix(desktop): contain history retry failures Keep exhausted recovery retries inside the notice action so retained history remains usable without an unhandled rejection. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- desktop/src/features/messages/ui/HistoryRefreshNotice.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx index 201a5405f3b..f4607a3c147 100644 --- a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx @@ -51,6 +51,10 @@ export function HistoryRefreshNotice({ ); try { await refreshChannelWindowMessages(client, channelId); + } catch { + // Exhausted query retries are already projected into refreshError. + // The recovery button owns this promise, so contain the rejection here. + return; } finally { // The observer can unmount before hydration/invalidation starts a fetch. // Retire an unclaimed token too, without clearing a newer button click.