diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176d..fd5bac10464 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -114,6 +114,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 ea3c2647865..b733bd1d5dd 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"; @@ -88,6 +84,7 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, editTarget = null, fetchOlder, + historyRevision, header, idleAuxiliaryPanel = null, idleAuxiliaryHeaderActions, @@ -226,12 +223,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( @@ -347,15 +349,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; - const directMessageIntro = React.useMemo( - () => - buildDirectMessageIntro({ - channel: activeChannel, - currentPubkey, - profiles, - }), - [activeChannel, currentPubkey, profiles], - ); const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -630,6 +623,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 1fe5bf751b8..6332bd4d343 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 c9330bc74c0..2f9195dcd0a 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -208,7 +208,7 @@ export function ChannelScreen({ threadScrollTargetId, ); useChannelSubscription(activeChannel); - const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } = + const { fetchOlder, isFetchingOlder } = useFetchOlderMessages(activeChannel); const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; @@ -255,8 +255,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({ @@ -851,6 +850,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/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 4a1ddf9d0b8..8356225047b 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"; @@ -64,6 +65,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 d28f2926081..9c0e59a5b23 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,3 +1,5 @@ +import { revalidateChannelWindow } from "./lib/revalidateChannelWindow"; +import { consumeChannelWindowRefreshIntent } from "./lib/channelWindowRefreshIntent"; import { useEffect, useEffectEvent } from "react"; import { type QueryClient, @@ -61,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, @@ -82,8 +86,6 @@ import { type MessageQueryContext = { optimisticId: string; - previousMessages: RelayEvent[]; - previousWindow: ChannelWindowStore | undefined; channelId: string; queryKey: ReturnType; }; @@ -263,6 +265,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 +277,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) { @@ -292,23 +313,69 @@ 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 = 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: latestOnly ? emptyChannelWindowStore() : retained, + readCurrent: () => + latestOnly + ? 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), + 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 +751,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 +784,6 @@ export function useSendMessageMutation( return { optimisticId: optimisticMessage.id, - previousMessages, - previousWindow, channelId: effectiveChannel.id, queryKey, }; @@ -732,11 +797,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 @@ -807,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/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/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 1bd921aabf4..49c9d731a5a 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; + /** 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. */ @@ -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)), }; @@ -205,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 { @@ -224,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, @@ -238,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) => { @@ -247,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/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 9594b937879..2ffc8d5da24 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -417,6 +417,114 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe } }); +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(); +}); + test("test_subscription_refresh_preserves_cold_history_error", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, 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 3787f8281a2..9742044df52 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -694,7 +694,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, @@ -702,6 +702,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 656e25d1b63..fb921a92ba4 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -370,7 +370,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.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 new file mode 100644 index 00000000000..f4607a3c147 --- /dev/null +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx @@ -0,0 +1,108 @@ +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) => { + const token = latest ? crypto.randomUUID() : undefined; + client.setQueryData( + channelWindowKey(channelId), + (current) => ({ + ...(current ?? emptyChannelWindowStore()), + refreshLatestOnly: token, + }), + ); + 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. + if (token) + client.setQueryData( + channelWindowKey(channelId), + (current) => + current?.refreshLatestOnly === token + ? { ...current, refreshLatestOnly: undefined } + : current, + ); + } + 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 b293be252af..5026da7504b 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -26,12 +26,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; @@ -62,7 +66,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 @@ -134,28 +140,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 @@ -183,6 +167,7 @@ const MessageTimelineBase = React.forwardRef< pinnedIntro, hasOlderMessages = true, historyExhausted = false, + historyRevision = 0, isFetchingOlder = false, followThreadById, huddleMemberPubkeys, @@ -253,13 +238,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(), @@ -311,45 +327,27 @@ const MessageTimelineBase = React.forwardRef< }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; const showTimelineError = timelineBodySurface === "error"; - 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({ - 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({ + const historyPagination = useHistoryPagination({ 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, @@ -438,10 +436,7 @@ const MessageTimelineBase = React.forwardRef< : 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 = @@ -466,9 +461,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, @@ -476,15 +472,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 }); @@ -576,33 +578,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, @@ -657,7 +632,7 @@ const MessageTimelineBase = React.forwardRef< channelName={channelName} channelType={channelType} currentPubkey={currentPubkey} - firstUnreadMessageId={firstUnreadMessageId} + firstUnreadMessageId={renderedSnapshot.firstUnreadMessageId} followThreadById={followThreadById} highlightedMessageId={highlightedMessageId} huddleMemberPubkeys={huddleMemberPubkeys} @@ -667,13 +642,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} @@ -683,7 +662,7 @@ const MessageTimelineBase = React.forwardRef< onOpenThread={onOpenThread} isSendingVideoReviewComment={isSendingVideoReviewComment} onSendVideoReviewComment={onSendVideoReviewComment} - onStartReached={loadOlderViaVirtualizer} + onStartReached={historyPagination.start} onToggleReaction={onToggleReaction} onVirtualizerApiChange={setTimelineVirtualizerApi} onVirtualizerRangeChanged={handleVirtualizerRangeChanged} @@ -725,7 +704,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")); + }} + /> +
{/* A frozen tail can be physically at bottom while live rows are still buffered. Keep the release action reachable in that state. */} - {!isAtBottom || bufferedTimeline.pendingCount > 0 ? ( + {!isAtBottom || pendingCount > 0 ? (
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 600df0d8c89..aa769193ee7 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"; @@ -447,12 +448,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); }, @@ -468,6 +465,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( () => @@ -493,6 +500,12 @@ function VirtualizedTimelineRows({ cancelBottomSettle, ); + const tryLoadOlder = useHistoryBoundaryIntent( + hostRef, + onStartReached, + armUpwardMomentum, + ); + const updatePinnedDayLabel = React.useCallback( (offset: number) => { const list = listRef.current; @@ -726,13 +739,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, ], @@ -745,6 +757,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..d2b6a8363f3 --- /dev/null +++ b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs @@ -0,0 +1,933 @@ +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"], + ); +}); + +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/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/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/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..57036028393 --- /dev/null +++ b/desktop/tests/e2e/history-transactions.spec.ts @@ -0,0 +1,545 @@ +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, + 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("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, +}) => { + 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/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, }) => { 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 8476d3dd321..ea9cab6abb5 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: @@ -248,7 +248,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 @@ -7043,7 +7043,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)