diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8ddfe38e8..81feccd0c 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -33,10 +33,16 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; +import { + mergeHistoryOverSnapshot, + readMessageSnapshot, + writeMessageSnapshot, +} from "@/features/messages/lib/messageSnapshot"; import { MIN_TOP_LEVEL_ROWS_PER_FETCH, pageOlderMessagesUntilRowFloor, } from "@/features/messages/lib/pageOlderMessages"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { KIND_STREAM_MESSAGE, KIND_SYSTEM_MESSAGE, @@ -171,10 +177,24 @@ export function createOptimisticMessage( export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); + const { activeWorkspace } = useWorkspaces(); + const relayUrl = activeWorkspace?.relayUrl ?? null; - return useQuery({ + const query = useQuery({ enabled: channel !== null && channel.channelType !== "forum", - placeholderData: () => queryClient.getQueryData(queryKey), + // Paint instantly from the in-memory cache, or — after a restart / gc — + // from the persisted per-channel snapshot, then revalidate behind it. + placeholderData: () => { + const cached = queryClient.getQueryData(queryKey); + if (cached && cached.length > 0) { + return cached; + } + if (!channel || !relayUrl) { + return undefined; + } + const snapshot = readMessageSnapshot(relayUrl, channel.id); + return snapshot ? normalizeTimelineMessages(snapshot) : undefined; + }, queryKey, queryFn: async () => { if (!channel) { @@ -185,36 +205,64 @@ export function useChannelMessagesQuery(channel: Channel | null) { channel.id, CHANNEL_HISTORY_LIMIT, ); - const currentMessages = - queryClient.getQueryData(queryKey) ?? []; - const mergedHistory = mergeTimelineHistoryMessages( - currentMessages, - history, - ); + // Merge over the cache, or over the persisted snapshot when cold; a + // cold snapshot load widens the aux backfill to the merged timeline so + // tombstones/edits for snapshot-only rows are fetched (see helper doc). + const cached = queryClient.getQueryData(queryKey); + const { merged: mergedHistory, auxBackfillWindow } = + mergeHistoryOverSnapshot({ + cached, + snapshot: + !cached && relayUrl + ? readMessageSnapshot(relayUrl, channel.id) + : null, + history, + }); // Paint messages immediately; backfill their reactions/edits/deletions // by `#e` in the background (it self-merges into the same cache key). - void backfillAuxForMessages(queryClient, channel.id, history); + void backfillAuxForMessages(queryClient, channel.id, auxBackfillWindow); - // Seed the cache, then — only if the cold window renders thinner than a - // normal scroll page — top it up to the same visible-row floor. A - // reply-heavy channel's 300-message cold load can be ~12 rows; a normal - // channel already clears the floor and skips the extra fetch entirely. + // Seed the cache and paint immediately; if the cold window renders + // thinner than a normal scroll page (reply-heavy channels), top it up + // in the background — it self-merges into the same cache key. queryClient.setQueryData(queryKey, mergedHistory); if ( countTopLevelTimelineRows(mergedHistory) < MIN_TOP_LEVEL_ROWS_PER_FETCH ) { - await pageOlderMessagesUntilRowFloor( + void pageOlderMessagesUntilRowFloor( queryClient, channel.id, () => true, - ); + ).catch((error) => { + console.error("Failed to top up channel history", channel.id, error); + }); } return queryClient.getQueryData(queryKey) ?? mergedHistory; }, staleTime: 5 * 60 * 1_000, - gcTime: 5 * 60 * 1_000, + // Long in-memory retention: a channel revisited within the hour paints + // from cache with zero relay round trips; the persisted snapshot covers + // restarts beyond it. + gcTime: 60 * 60 * 1_000, + }); + + // Persist the newest slice after each settled update so the next cold open + // (restart, gc) paints from the snapshot. Placeholder frames are skipped — + // they are what the snapshot painted, not new information. + const persistSnapshot = useEffectEvent((events: RelayEvent[]) => { + if (relayUrl && channel) { + writeMessageSnapshot(relayUrl, channel.id, events); + } }); + const settledData = query.isPlaceholderData ? undefined : query.data; + useEffect(() => { + if (settledData && settledData.length > 0) { + persistSnapshot(settledData); + } + }, [settledData]); + + return query; } export function useChannelSubscription(channel: Channel | null) { diff --git a/desktop/src/features/messages/lib/messageSnapshot.test.mjs b/desktop/src/features/messages/lib/messageSnapshot.test.mjs new file mode 100644 index 000000000..a188f51da --- /dev/null +++ b/desktop/src/features/messages/lib/messageSnapshot.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + mergeHistoryOverSnapshot, + messageSnapshotKey, + readMessageSnapshot, + removeMessageSnapshotsForRelay, + writeMessageSnapshot, +} from "./messageSnapshot.ts"; + +if (typeof globalThis.window === "undefined") { + const storage = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + key: (index) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + }, + }, + }; +} + +function makeEvent(overrides = {}) { + return { + id: `event-${Math.random().toString(36).slice(2)}`, + pubkey: "pubkey-1", + created_at: 1_700_000_000, + kind: 9, + tags: [["h", "chan-1"]], + content: "hello", + sig: "sig", + ...overrides, + }; +} + +const RELAY = "wss://relay.example.com"; + +function clearRelay(relayUrl = RELAY) { + removeMessageSnapshotsForRelay(relayUrl); +} + +test("messageSnapshotKey: normalizes trailing slash and case", () => { + assert.equal( + messageSnapshotKey("WSS://Relay.Example.com/", "chan-1"), + messageSnapshotKey("wss://relay.example.com", "chan-1"), + ); +}); + +test("read after write returns the persisted events", () => { + clearRelay(); + const events = [makeEvent({ id: "a" }), makeEvent({ id: "b" })]; + writeMessageSnapshot(RELAY, "chan-1", events); + assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), events); +}); + +test("read for an unknown channel returns null", () => { + clearRelay(); + assert.equal(readMessageSnapshot(RELAY, "chan-never"), null); +}); + +test("read returns null for malformed JSON", () => { + window.localStorage.setItem( + messageSnapshotKey(RELAY, "chan-bad"), + "not-json{{{", + ); + assert.equal(readMessageSnapshot(RELAY, "chan-bad"), null); +}); + +test("read returns null for a wrong-version payload", () => { + window.localStorage.setItem( + messageSnapshotKey(RELAY, "chan-v2"), + JSON.stringify({ version: 2, updatedAt: 1, events: [makeEvent()] }), + ); + assert.equal(readMessageSnapshot(RELAY, "chan-v2"), null); +}); + +test("pending optimistic events are not persisted", () => { + clearRelay(); + const settled = makeEvent({ id: "settled" }); + writeMessageSnapshot(RELAY, "chan-1", [ + settled, + makeEvent({ id: "optimistic", pending: true }), + ]); + assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), [settled]); +}); + +test("write with only pending events persists nothing", () => { + clearRelay(); + writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ pending: true })]); + assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); +}); + +test("snapshot keeps only the newest slice of a long timeline", () => { + clearRelay(); + const events = Array.from({ length: 200 }, (_, i) => + makeEvent({ id: `event-${i}`, created_at: 1_700_000_000 + i }), + ); + writeMessageSnapshot(RELAY, "chan-1", events); + const persisted = readMessageSnapshot(RELAY, "chan-1"); + assert.equal(persisted.length, 80); + assert.equal(persisted[persisted.length - 1].id, "event-199"); + assert.equal(persisted[0].id, "event-120"); +}); + +test("per-relay channel cap evicts the least recently written snapshot", () => { + clearRelay(); + for (let i = 0; i < 21; i++) { + writeMessageSnapshot(RELAY, `chan-${i}`, [makeEvent({ id: `e-${i}` })]); + } + // chan-0 was written first (oldest updatedAt tie broken by insertion) — + // with 21 channels, at least one of the earliest must be evicted and the + // newest retained. + assert.notEqual(readMessageSnapshot(RELAY, "chan-20"), null); + const retained = Array.from({ length: 21 }, (_, i) => + readMessageSnapshot(RELAY, `chan-${i}`), + ).filter((snapshot) => snapshot !== null); + assert.equal(retained.length, 20); +}); + +test("remove clears every snapshot for that relay only", () => { + clearRelay(); + clearRelay("wss://other.example.com"); + writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ id: "keep-other" })]); + writeMessageSnapshot("wss://other.example.com", "chan-1", [ + makeEvent({ id: "other" }), + ]); + removeMessageSnapshotsForRelay(RELAY); + assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); + assert.notEqual( + readMessageSnapshot("wss://other.example.com", "chan-1"), + null, + ); +}); + +test("write is tolerant of storage failures", () => { + const original = window.localStorage.setItem; + window.localStorage.setItem = () => { + throw new Error("quota exceeded"); + }; + try { + assert.doesNotThrow(() => + writeMessageSnapshot(RELAY, "chan-1", [makeEvent()]), + ); + } finally { + window.localStorage.setItem = original; + } +}); + +test("cold snapshot load: merge keeps snapshot-only rows and widens aux backfill to them", () => { + const snapshotOnly = makeEvent({ id: "ghost", created_at: 1_700_000_000 }); + const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); + const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ + cached: undefined, + snapshot: [snapshotOnly], + history: [fresh], + }); + assert.deepEqual( + merged.map((event) => event.id), + ["ghost", "fresh"], + ); + assert.ok(auxBackfillWindow.some((event) => event.id === "ghost")); + assert.ok(auxBackfillWindow.some((event) => event.id === "fresh")); +}); + +test("warm load: aux backfill stays scoped to the fresh window", () => { + const cached = makeEvent({ id: "cached", created_at: 1_700_000_000 }); + const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); + const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ + cached: [cached], + snapshot: [makeEvent({ id: "stale-snapshot" })], + history: [fresh], + }); + assert.ok(merged.some((event) => event.id === "cached")); + assert.deepEqual( + auxBackfillWindow.map((event) => event.id), + ["fresh"], + ); +}); + +test("cold load without a snapshot backfills the fresh window only", () => { + const fresh = makeEvent({ id: "fresh" }); + const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ + cached: undefined, + snapshot: null, + history: [fresh], + }); + assert.deepEqual( + merged.map((event) => event.id), + ["fresh"], + ); + assert.deepEqual( + auxBackfillWindow.map((event) => event.id), + ["fresh"], + ); +}); diff --git a/desktop/src/features/messages/lib/messageSnapshot.ts b/desktop/src/features/messages/lib/messageSnapshot.ts new file mode 100644 index 000000000..4b83742e1 --- /dev/null +++ b/desktop/src/features/messages/lib/messageSnapshot.ts @@ -0,0 +1,201 @@ +/** + * Per-channel persisted message snapshots. + * + * A channel revisited after its React-Query cache entry is gone (app restart, + * gcTime expiry, workspace remount) goes fully cold and holds a skeleton for a + * relay round trip. This module persists the newest slice of each channel's + * timeline so a revisit can paint instantly from the snapshot while the + * history fetch revalidates behind it — the same stale-then-revalidate pattern + * the sidebar's channelSnapshot uses for the channel list. + * + * Keyed per relay URL + channel id so one relay's messages never bleed into + * another. Bounded two ways: only the newest MAX_EVENTS_PER_SNAPSHOT events + * per channel, and only the MAX_CHANNELS_PER_RELAY most recently written + * channels per relay (older ones are evicted LRU on write). + */ + +import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys"; +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { RelayEvent } from "@/shared/api/types"; + +const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1"; + +// Newest events kept per channel. The trailing slice of the sorted timeline +// cache, so recent auxiliary events (reactions/edits) ride along with the +// content rows they decorate. +const MAX_EVENTS_PER_SNAPSHOT = 80; + +const MAX_CHANNELS_PER_RELAY = 20; + +export function messageSnapshotKey(relayUrl: string, channelId: string) { + return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:${channelId}`; +} + +type SnapshotPayload = { + version: 1; + updatedAt: number; + events: RelayEvent[]; +}; + +function parseSnapshotPayload(json: unknown): SnapshotPayload | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1 || !Array.isArray(obj.events)) return null; + const updatedAt = + typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) + ? obj.updatedAt + : 0; + return { version: 1, updatedAt, events: obj.events as RelayEvent[] }; +} + +/** + * Reads the persisted message snapshot for a channel, or null when absent or + * malformed. + */ +export function readMessageSnapshot( + relayUrl: string, + channelId: string, +): RelayEvent[] | null { + try { + const raw = window.localStorage.getItem( + messageSnapshotKey(relayUrl, channelId), + ); + if (!raw) return null; + const parsed = parseSnapshotPayload(JSON.parse(raw)); + if (!parsed || parsed.events.length === 0) return null; + return parsed.events; + } catch { + return null; + } +} + +function relayPrefix(relayUrl: string) { + return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; +} + +function collectKeysWithPrefix(prefix: string): string[] { + const keys: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(prefix)) { + keys.push(key); + } + } + return keys; +} + +function evictOldestSnapshots(prefix: string, keepingKey: string) { + const others = collectKeysWithPrefix(prefix).filter( + (key) => key !== keepingKey, + ); + if (others.length < MAX_CHANNELS_PER_RELAY) { + return; + } + + const byAge = others + .map((key) => { + let updatedAt = 0; + try { + const parsed = parseSnapshotPayload( + JSON.parse(window.localStorage.getItem(key) ?? ""), + ); + updatedAt = parsed?.updatedAt ?? 0; + } catch { + // Malformed entries sort oldest and get evicted first. + } + return { key, updatedAt }; + }) + .sort((a, b) => a.updatedAt - b.updatedAt); + + for (const { key } of byAge.slice( + 0, + others.length - (MAX_CHANNELS_PER_RELAY - 1), + )) { + window.localStorage.removeItem(key); + } +} + +/** + * Persists the newest slice of a channel's timeline. Pending optimistic events + * are dropped (they have no relay identity to revalidate against). Skips the + * write when unchanged so live-append churn does not re-serialize an identical + * snapshot. Non-fatal on storage failure (e.g. quota exceeded). + */ +export function writeMessageSnapshot( + relayUrl: string, + channelId: string, + events: RelayEvent[], +): void { + try { + const persistable = events + .filter((event) => !event.pending) + .slice(-MAX_EVENTS_PER_SNAPSHOT); + if (persistable.length === 0) { + return; + } + + const key = messageSnapshotKey(relayUrl, channelId); + const previous = window.localStorage.getItem(key); + if (previous) { + const parsed = parseSnapshotPayload(JSON.parse(previous)); + if ( + parsed && + JSON.stringify(parsed.events) === JSON.stringify(persistable) + ) { + return; + } + } + + evictOldestSnapshots(relayPrefix(relayUrl), key); + window.localStorage.setItem( + key, + JSON.stringify({ + version: 1, + updatedAt: Date.now(), + events: persistable, + } satisfies SnapshotPayload), + ); + } catch { + // Storage access failures are non-fatal. + } +} + +/** + * Merge a fresh history window over the in-memory cache — or, when cold, over + * the persisted snapshot — and pick the window aux backfill must cover. + * + * The snapshot can hold events older than the fetch window; dropping them on + * settle would visibly shrink an already-painted timeline, so the merge keeps + * them. But a kept snapshot row deleted/edited while the app was closed never + * reappears in any history fetch (the relay soft-deletes), so its tombstone or + * edit is only reachable by `#e` over that row's id — cold snapshot loads must + * therefore backfill over the merged timeline, not just the fresh window. + * Otherwise the ghost paints, and the post-settle snapshot rewrite persists it + * forever. + */ +export function mergeHistoryOverSnapshot(input: { + cached: RelayEvent[] | undefined; + snapshot: RelayEvent[] | null; + history: RelayEvent[]; +}): { merged: RelayEvent[]; auxBackfillWindow: RelayEvent[] } { + const usedSnapshot = !input.cached && input.snapshot !== null; + const merged = mergeTimelineHistoryMessages( + input.cached ?? input.snapshot ?? [], + input.history, + ); + return { merged, auxBackfillWindow: usedSnapshot ? merged : input.history }; +} + +/** + * Removes every channel message snapshot for a relay. Called when a workspace + * is removed. + */ +export function removeMessageSnapshotsForRelay(relayUrl: string): void { + try { + for (const key of collectKeysWithPrefix(relayPrefix(relayUrl))) { + window.localStorage.removeItem(key); + } + } catch { + // Storage access failures are non-fatal. + } +} diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index 6cc89e3f9..9509d70e4 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -31,6 +31,10 @@ export type PageOlderResult = { hasOlderMessages: boolean; }; +// One paging pass per channel at a time: the background cold-load top-up and +// a scroll-up fetch share the running pass instead of overlapping REQs. +const inFlightPasses = new Map>(); + /** * Page older history into the channel cache until the timeline has gained * {@link MIN_TOP_LEVEL_ROWS_PER_FETCH} visible rows, history runs out, or the @@ -40,7 +44,26 @@ export type PageOlderResult = { * `shouldContinue` lets the caller bail mid-pass (e.g. channel switch). Returns * whether more history is believed to remain. */ -export async function pageOlderMessagesUntilRowFloor( +export function pageOlderMessagesUntilRowFloor( + queryClient: QueryClient, + channelId: string, + shouldContinue: () => boolean, +): Promise { + const inFlight = inFlightPasses.get(channelId); + if (inFlight) { + return inFlight; + } + + const pass = runPageOlderPass(queryClient, channelId, shouldContinue).finally( + () => { + inFlightPasses.delete(channelId); + }, + ); + inFlightPasses.set(channelId, pass); + return pass; +} + +async function runPageOlderPass( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, diff --git a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs index bb32831c3..fe6960f74 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs +++ b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs @@ -85,6 +85,29 @@ test("initial load holds the skeleton while the cold-load top-up fetches", () => ); }); +test("pre-settle placeholder rows paint immediately (snapshot revisit)", () => { + // A revisit painting from the React-Query cache or a persisted snapshot: + // placeholder rows are a previously-settled timeline, not a partial cold + // load — show them stale-then-revalidate instead of a skeleton. + assert.equal( + selectTimelineLoadingState( + { ...settled, isFetching: true, isPlaceholderData: true, dataLength: 8 }, + false, + ), + false, + ); +}); + +test("pre-settle EMPTY placeholder still holds the skeleton", () => { + assert.equal( + selectTimelineLoadingState( + { ...settled, isFetching: true, isPlaceholderData: true, dataLength: 0 }, + false, + ), + true, + ); +}); + test("settled channel with rows mid-refetch is not loading", () => { // Same query shape, but after first settle: the latch owns refetch blips, so // present rows mean loaded. diff --git a/desktop/src/features/messages/lib/timelineLoadingState.ts b/desktop/src/features/messages/lib/timelineLoadingState.ts index 001c06341..ea46168d2 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.ts +++ b/desktop/src/features/messages/lib/timelineLoadingState.ts @@ -22,12 +22,16 @@ export function selectTimelineLoadingState( if (status.isPending) { return true; } - // Before the first settle, hold the skeleton for the whole cold load — the - // row-floor top-up keeps `isFetching` true after the cache already has rows, - // and dropping the skeleton there exposes the older-fetch spinner on first - // load. After settle, the latch protects against refetch blips, so once real - // rows are present we are loaded even mid-refetch. if (!hasSettled) { + // Placeholder rows are a previously-settled timeline (React-Query cache on + // revisit, or a persisted snapshot) — paint them stale-then-revalidate + // instead of holding a skeleton over known content. + if (status.isPlaceholderData && (status.dataLength ?? 0) > 0) { + return false; + } + // Otherwise hold the skeleton for the whole cold load: the live + // subscription can seed a few rows before the history fetch settles, and + // painting those as if loaded flashes a near-empty timeline. return status.isFetching; } return ( diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx index d44b0bd70..e984ea492 100644 --- a/desktop/src/features/workspaces/useWorkspaces.tsx +++ b/desktop/src/features/workspaces/useWorkspaces.tsx @@ -18,6 +18,7 @@ import { } from "./workspaceStorage"; import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; +import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; export type UseWorkspacesReturn = { workspaces: Workspace[]; @@ -118,6 +119,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn { if (removed) { removeSelfProfileCachesForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); + removeMessageSnapshotsForRelay(removed.relayUrl); } } diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index c12f29149..ba19b38bb 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -63,7 +63,7 @@ async function getMessagePosition( }, messageId); } -test("first channel load holds skeleton instead of showing older-history spinner", async ({ +test("first channel load paints the first window without waiting for the row-floor top-up", async ({ page, }) => { await installMockBridge(page); @@ -93,22 +93,23 @@ test("first channel load holds skeleton instead of showing older-history spinner window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_500 }, + mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 5_000 }, }; }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + // The reply-heavy window renders < MIN_TOP_LEVEL_ROWS_PER_FETCH top-level + // rows, which triggers the row-floor top-up — paced at 5s per page above. + // First paint must NOT wait for it: rows appear well inside that window, + // proving the top-up runs in the background rather than gating the queryFn. const timeline = page.getByTestId("message-timeline"); - await expect(timeline.locator(".t-skel-bar").first()).toBeVisible(); - await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount( - 0, - ); - await expect(timeline.locator("[data-message-id]").first()).toBeVisible({ - timeout: 5_000, + timeout: 4_000, }); + // And once rows have painted, the cold-load skeleton must be gone. + await expect(timeline.locator(".t-skel-bar")).toHaveCount(0); }); test("preserves user scroll while older channel history loads", async ({