Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof vi.spyOn>;

beforeEach(() => {
mocks.activeServerId = TEST_SERVER;
setVisibilityState('visible');
setRealtimeSocketFactoryForTests(() => ({
binaryType: 'arraybuffer',
readyState: 0,
Expand All @@ -52,6 +65,7 @@ describe('useMayHaveMissedMessagesCallback', () => {
});

afterEach(() => {
setVisibilityState('visible');
vi.useRealTimers();
eventBusManager.stopBus(TEST_SERVER);
setRealtimeSocketFactoryForTests(null);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 62 additions & 0 deletions apps/frontend/src/lib/state/room/messages.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
44 changes: 30 additions & 14 deletions apps/frontend/src/lib/state/room/messages/MessagesStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoomEventView['event']>;
type MessagePostedPayload = Extract<RoomEventPayload, { kind: typeof RoomEventKind.MessagePosted }>;
type MessageEditedPayload = Extract<RoomEventPayload, { kind: typeof RoomEventKind.MessageEdited }>;
Expand All @@ -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}`;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -888,14 +895,15 @@ export class MessagesStore {
hasOlder?: boolean;
},
existingBeforeFetch: ReadonlySet<string>,
options: { preserveExistingWindow?: boolean } = {}
options: { cursorMode?: SnapshotCursorMode } = {}
): void {
const fetched = unmask(connection.events);
const newSeen = new SvelteSet<string>();
const merged: RoomEventView[] = [];
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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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
});
}

Expand All @@ -941,11 +954,11 @@ export class MessagesStore {
): Promise<RefreshCurrentWindowResult> {
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 };
}

Expand All @@ -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 };
}

Expand All @@ -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 };
Expand Down