diff --git a/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.spec.ts b/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.spec.ts index 4a4133834..154255130 100644 --- a/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.spec.ts +++ b/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.spec.ts @@ -22,6 +22,10 @@ vi.mock('$lib/state/server/connection.svelte', () => ({ useConnection: () => () => ({ reconnectCount: 0 }) })); +vi.mock('$lib/hooks/useReconnectCallback.svelte', () => ({ + useReconnectCallback: () => undefined +})); + class FakeServerConnection { reconnectCount = $state(0); realtimeUrl = 'ws://test-server/api/realtime'; @@ -33,11 +37,20 @@ class FakeServerConnection { const TEST_SERVER = 'test-server'; +function setVisibilityState(value: DocumentVisibilityState): void { + Object.defineProperty(document, 'visibilityState', { + value, + configurable: true + }); + document.dispatchEvent(new Event('visibilitychange')); +} + describe('useMayHaveMissedMessagesCallback', () => { let consoleDebug: ReturnType; beforeEach(() => { mocks.activeServerId = TEST_SERVER; + setVisibilityState('visible'); setRealtimeSocketFactoryForTests(() => ({ binaryType: 'arraybuffer', readyState: 0, @@ -52,6 +65,7 @@ describe('useMayHaveMissedMessagesCallback', () => { }); afterEach(() => { + setVisibilityState('visible'); vi.useRealTimers(); eventBusManager.stopBus(TEST_SERVER); setRealtimeSocketFactoryForTests(null); @@ -88,6 +102,64 @@ describe('useMayHaveMissedMessagesCallback', () => { rendered.unmount(); }); + it('skips short browser visibility resumes', async () => { + vi.useFakeTimers(); + const onSignal = vi.fn().mockResolvedValue(undefined); + + const rendered = render(Harness, { props: { onSignal } }); + flushSync(); + + setVisibilityState('hidden'); + await vi.advanceTimersByTimeAsync(1_449); + setVisibilityState('visible'); + await vi.advanceTimersByTimeAsync(1_000); + + expect(onSignal).not.toHaveBeenCalled(); + rendered.unmount(); + }); + + it('skips short browser pageshow resumes with known hidden duration', async () => { + vi.useFakeTimers(); + const onSignal = vi.fn().mockResolvedValue(undefined); + + const rendered = render(Harness, { props: { onSignal } }); + flushSync(); + + setVisibilityState('hidden'); + await vi.advanceTimersByTimeAsync(1_449); + setVisibilityState('visible'); + window.dispatchEvent(new Event('pageshow')); + await vi.advanceTimersByTimeAsync(1_000); + + expect(onSignal).not.toHaveBeenCalled(); + rendered.unmount(); + }); + + it('runs the callback for longer browser visibility resumes', async () => { + vi.useFakeTimers(); + const onSignal = vi.fn().mockResolvedValue(undefined); + + const rendered = render(Harness, { props: { onSignal } }); + flushSync(); + + setVisibilityState('hidden'); + await vi.advanceTimersByTimeAsync(30_000); + setVisibilityState('visible'); + await vi.advanceTimersByTimeAsync(1_000); + + expect(onSignal).toHaveBeenCalledOnce(); + expect(onSignal).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: TEST_SERVER, + reason: 'visibility', + phase: 'immediate', + source: 'browser', + hiddenDurationMs: 30_000 + }) + ); + rendered.unmount(); + }); + it('coalesces a browser wake burst into one signal', async () => { vi.useFakeTimers(); const onSignal = vi.fn().mockResolvedValue(undefined); diff --git a/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.ts b/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.ts index ea8df403b..a7e581f6b 100644 --- a/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.ts +++ b/apps/frontend/src/lib/hooks/useMayHaveMissedMessagesCallback.svelte.ts @@ -21,11 +21,21 @@ export type MayHaveMissedMessagesReason = | 'manual-shortcut'; const DEDUPE_MS = 1_000; +const SHORT_BROWSER_RESUME_SKIP_MS = 30_000; function isEventBusReason(reason: MayHaveMissedMessagesReason): boolean { return reason.startsWith('event-bus-'); } +function shouldSkipShortBrowserResume(signal: ResumeSignal): boolean { + return ( + signal.source === 'browser' && + (signal.reason === 'visibility' || signal.reason === 'pageshow') && + signal.hiddenDurationMs !== null && + signal.hiddenDurationMs < SHORT_BROWSER_RESUME_SKIP_MS + ); +} + function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); @@ -89,6 +99,10 @@ function createRefreshRunner( return { trigger(signal: ResumeSignal): void { const now = Date.now(); + if (shouldSkipShortBrowserResume(signal)) { + console.debug('[room-refresh] skipped short browser resume signal', signal); + return; + } if (inFlight) { queuedSignal = signal; console.debug('[room-refresh] queued maybe-missed signal while refresh is running', signal); diff --git a/apps/frontend/src/lib/state/room/messages.svelte.spec.ts b/apps/frontend/src/lib/state/room/messages.svelte.spec.ts index d8ea12870..a25c47771 100644 --- a/apps/frontend/src/lib/state/room/messages.svelte.spec.ts +++ b/apps/frontend/src/lib/state/room/messages.svelte.spec.ts @@ -1009,6 +1009,68 @@ describe('MessagesStore — room lifecycle ownership', () => { store.dispose(); }); + it('soft-refreshes the latest room window without collapsing already-loaded history', async () => { + const initialLatest = Array.from({ length: 50 }, (_, index) => + threadMessageEvent(`m${index + 51}`) + ); + const older = Array.from({ length: 50 }, (_, index) => threadMessageEvent(`m${index + 1}`)); + const refreshedLatest = Array.from({ length: 100 }, (_, index) => { + const id = `m${index + 2}`; + return id === 'm90' ? messageWithReaction(id, 'thumbsup') : threadMessageEvent(id); + }); + const fake = new FakeQueryClient([ + roomEventsResult({ + events: initialLatest, + startCursor: 'tl:cursor-51', + endCursor: 'tl:cursor-100', + hasOlder: true, + hasNewer: false + }), + roomEventsResult({ + events: older, + startCursor: 'tl:cursor-1', + endCursor: 'tl:cursor-50', + hasOlder: false, + hasNewer: true + }), + roomEventsResult({ + events: refreshedLatest, + startCursor: 'tl:cursor-2', + endCursor: 'tl:cursor-101', + hasOlder: true, + hasNewer: false + }) + ]); + const store = new MessagesStore( + fake as unknown as ServerConnection, + () => null, + timelineFromFixtures(fake) + ); + + store.setRoom('room-1'); + await settle(); + await store.loadMore(); + await settle(); + expect(store.rootEvents).toHaveLength(100); + fake.queryMock.mockClear(); + + await store.refreshCurrentWindow(); + await settle(); + + expect(store.isInitialLoading).toBe(false); + expect(store.rootEvents.map((event) => event.id)).toEqual( + Array.from({ length: 101 }, (_, index) => `m${index + 1}`) + ); + expect(store.rootEvents.find((event) => event.id === 'm90')?.event).toMatchObject({ + reactions: [{ emoji: 'thumbsup', count: 1 }] + }); + expect(store.hasReachedStart).toBe(true); + expect(fake.queryMock).toHaveBeenCalledOnce(); + expect(fake.queryMock.mock.calls[0][1]).toEqual({ roomId: 'room-1', limit: 100 }); + expect(fake.queryMock.mock.calls[0][2]).toEqual({ requestPolicy: 'network-only' }); + store.dispose(); + }); + it('soft-refreshes around an anchor event when one is provided', async () => { const fake = new FakeQueryClient([ roomEventsResult({ diff --git a/apps/frontend/src/lib/state/room/messages/MessagesStore.svelte.ts b/apps/frontend/src/lib/state/room/messages/MessagesStore.svelte.ts index 86191bd4d..f94d88689 100644 --- a/apps/frontend/src/lib/state/room/messages/MessagesStore.svelte.ts +++ b/apps/frontend/src/lib/state/room/messages/MessagesStore.svelte.ts @@ -11,6 +11,7 @@ import { isRootRoomEvent, isThreadEvent } from './filters'; import { type EventConnectionPage, type RawEvent, getActorId, unmask } from './helpers'; type MessageScope = 'room' | 'thread'; +type SnapshotCursorMode = 'replace' | 'preserve' | 'latest'; type RoomEventPayload = NonNullable; type MessagePostedPayload = Extract; type MessageEditedPayload = Extract; @@ -33,6 +34,8 @@ export type RefreshCurrentWindowResult = { refreshed: boolean; }; +const MAX_SOFT_REFRESH_PAGE_SIZE = 500; + function eventCacheKey(roomId: string, eventId: string): string { return `${roomId}\u0000${eventId}`; } @@ -240,6 +243,10 @@ export class MessagesStore { return this.#loadId !== thisLoad; } + private softRefreshLimit(): number { + return Math.min(Math.max(PAGE_SIZE, this.events.length), MAX_SOFT_REFRESH_PAGE_SIZE); + } + private previewKey(eventId: string): string { return eventCacheKey(this.roomId, eventId); } @@ -888,7 +895,7 @@ export class MessagesStore { hasOlder?: boolean; }, existingBeforeFetch: ReadonlySet, - options: { preserveExistingWindow?: boolean } = {} + options: { cursorMode?: SnapshotCursorMode } = {} ): void { const fetched = unmask(connection.events); const newSeen = new SvelteSet(); @@ -896,6 +903,7 @@ export class MessagesStore { const previousOldestCursor = this.oldestCursor; const previousNewestCursor = this.newestCursor; const previousHasReachedStart = this.hasReachedStart; + const cursorMode = options.cursorMode ?? 'replace'; for (const e of fetched) { if (newSeen.has(e.id)) continue; @@ -904,11 +912,11 @@ export class MessagesStore { } // Preserve subscription events that arrived while the refresh query was in - // flight. Anchored refreshes also preserve already-loaded rows outside the - // fetched window so returning from another tab does not visually collapse a - // long scrolled buffer. + // flight. Soft catch-up refreshes also preserve already-loaded rows outside + // the fetched window so returning from another tab does not visually + // collapse a long scrolled buffer. for (const e of this.events) { - if (!options.preserveExistingWindow && existingBeforeFetch.has(e.id)) continue; + if (cursorMode === 'replace' && existingBeforeFetch.has(e.id)) continue; if (newSeen.has(e.id)) continue; newSeen.add(e.id); merged.push(e); @@ -917,7 +925,11 @@ export class MessagesStore { this.events = merged; if (this.scope === 'room') this.sortRoomEvents(); this.seenIds = newSeen; - if (options.preserveExistingWindow) { + if (cursorMode === 'latest') { + this.oldestCursor = previousOldestCursor ?? connection.startCursor ?? undefined; + this.newestCursor = connection.endCursor ?? previousNewestCursor ?? undefined; + this.hasReachedStart = previousHasReachedStart || !(connection.hasOlder ?? false); + } else if (cursorMode === 'preserve') { this.oldestCursor = previousOldestCursor ?? connection.startCursor ?? undefined; this.newestCursor = previousNewestCursor ?? connection.endCursor ?? undefined; this.hasReachedStart = previousHasReachedStart || !(connection.hasOlder ?? false); @@ -931,7 +943,8 @@ export class MessagesStore { preservedExistingCount: merged.length - fetched.length, eventCount: this.events.length, hasOlder: connection.hasOlder ?? false, - hasReachedStart: this.hasReachedStart + hasReachedStart: this.hasReachedStart, + cursorMode }); } @@ -941,11 +954,11 @@ export class MessagesStore { ): Promise { const page = await this.roomTimeline.getRoomEvents({ roomId: this.roomId, - limit: PAGE_SIZE + limit: this.softRefreshLimit() }); if (this.isStale(thisLoad)) return { hasOlder: false, hasNewer: false, refreshed: false }; - this.replaceWithSnapshotAndUpdateCursors(page, existingBeforeFetch); + this.replaceWithSnapshotAndUpdateCursors(page, existingBeforeFetch, { cursorMode: 'latest' }); return { hasOlder: page.hasOlder, hasNewer: page.hasNewer, refreshed: true }; } @@ -961,9 +974,7 @@ export class MessagesStore { }); if (this.isStale(thisLoad)) return { hasOlder: false, hasNewer: false, refreshed: false }; - this.replaceWithSnapshotAndUpdateCursors(page, existingBeforeFetch, { - preserveExistingWindow: true - }); + this.replaceWithSnapshotAndUpdateCursors(page, existingBeforeFetch, { cursorMode: 'preserve' }); return { hasOlder: page.hasOlder, hasNewer: page.hasNewer, refreshed: true }; } @@ -982,11 +993,16 @@ export class MessagesStore { : await this.roomTimeline.getThreadEvents({ roomId: this.roomId, threadRootEventId: this.threadRootEventId, - limit: PAGE_SIZE + limit: this.softRefreshLimit() }); if (this.isStale(thisLoad)) return { hasOlder: false, hasNewer: false, refreshed: false }; this.replaceWithSnapshotAndUpdateCursors(page, existingBeforeFetch, { - preserveExistingWindow: anchorEventId !== null && anchorEventId !== this.threadRootEventId + cursorMode: + anchorEventId === null + ? 'latest' + : anchorEventId !== this.threadRootEventId + ? 'preserve' + : 'replace' }); this.sortThreadEvents(); return { hasOlder: page.hasOlder, hasNewer: page.hasNewer, refreshed: true };