perf(app): reduce redundant data fetching

Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+4456 -1400

No files matched your search

@@ -0,0 +1,313 @@
import { act, renderHook } from '@testing-library/react-native';
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
import { usePacksStore } from '@/store/packsStore';
import { useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const baseline: SyncSummary = {
server_time: '2026-08-27T12:00:00.000Z',
couple_id: 'couple',
profile_updated_at: '2026-08-01T00:00:00.000Z',
partner_id: 'partner',
partner_updated_at: '2026-08-01T00:00:00.000Z',
match_count: 3,
new_match_count: 0,
latest_match_at: '2026-08-20T00:00:00.000Z',
pending_yours: 1,
pending_theirs: 2,
unread_total: 0,
enabled_packs_fingerprint: 'digest-a',
match_state_fingerprint: 'state-a',
match_unread_fingerprint: 'unread-a',
streak_updated_at: '2026-08-01T00:00:00.000Z',
};
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
await flush();
};
/** Replaces every domain refresh the sync can dispatch with a counter. */
function stubRefreshers() {
const fetchUser = jest.fn(async () => undefined);
const fetchCouple = jest.fn(async () => undefined);
const fetchMatches = jest.fn(async () => undefined);
const fetchPendingQuestions = jest.fn(async () => undefined);
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
const fetchEnabledPacks = jest.fn(async () => undefined);
const refreshStreak = jest.fn(async () => undefined);
useAuthStore.setState({ fetchUser, fetchCouple } as never);
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
usePacksStore.setState({ fetchEnabledPacks } as never);
useStreakStore.setState({ refreshStreak } as never);
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
}
describe('useCoupleSync', () => {
let refreshers: ReturnType<typeof stubRefreshers>;
const originals = {
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
match: {
fetchMatches: useMatchStore.getState().fetchMatches,
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
},
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
useMessageStore.setState({ unreadCount: 0 });
// Every view starts loaded: this hook must refresh only what is on screen,
// and the "not loaded" case is asserted separately below.
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
refreshers = stubRefreshers();
});
afterEach(() => {
jest.useRealTimers();
useAuthStore.setState(originals.auth as never);
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
useStreakStore.setState(originals.streak as never);
});
const answer = (...summaries: SyncSummary[]) => {
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
mock.mockResolvedValue(summaries[summaries.length - 1]);
};
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
answer(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
// Three polls, three requests in total: the baseline poll must not trigger
// the refresh of five domains that the old five-second sweep performed.
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('applies the unread total from the summary without a second request', async () => {
answer(baseline, { ...baseline, unread_total: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('refreshes only the domain whose marker moved', async () => {
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('routes each remaining marker to its own domain', async () => {
answer(baseline, {
...baseline,
pending_yours: 2,
pending_theirs: 3,
enabled_packs_fingerprint: 'digest-b',
partner_updated_at: '2026-08-29T00:00:00.000Z',
streak_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
// A partner profile change is a couple-level read; the whole user is only
// re-read when the user's own profile or couple membership moved.
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
// Counts and latest-created-at do not move for an in-place match_type/
// response_summary edit; only the state fingerprint does.
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
// One match is read while another receives a message: unread_total holds
// steady but the per-match fingerprint (and unread-first ordering) moves.
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('does not refresh enabled packs when that domain has never been opened', async () => {
usePacksStore.setState({ enabledPacksLoadedAt: null });
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
});
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
answer({ ...baseline, match_count: 3 });
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
await tick(); // establishes alice's baseline (match_count: 3)
// Switch accounts: bob's couple has a completely different match_count.
// Without a baseline reset this would look like a 12-count jump and
// trigger a refresh purely from comparing across accounts.
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
answer(baseline, {
...baseline,
profile_updated_at: '2026-08-29T00:00:00.000Z',
partner_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('leaves views the user has never opened unloaded', async () => {
useMatchStore.setState({ loadedAt: {} });
answer(baseline, {
...baseline,
match_count: 9,
latest_match_at: '2026-08-29T00:00:00.000Z',
pending_yours: 7,
pending_theirs: 8,
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
});
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
answer(baseline);
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
await flush();
await tick();
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
unmount();
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => {
mockForeground.value = false;
for (const listener of [...mockListeners]) listener(false);
});
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
});
it('discards a summary that lands after the account it belongs to was switched away', async () => {
// Alice's poll is still open when Bob signs in. Its answer describes a
// couple Bob is not in, so none of it may land: not his badge, not the
// baseline his next poll is diffed against, not a refresh decision.
let answerAlice: (summary: SyncSummary) => void = () => undefined;
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
expect(mock).toHaveBeenCalledTimes(1);
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
mock.mockResolvedValue(bobBaseline);
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Alice's request finally answers, long after she stopped being the user.
await act(async () => {
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
await Promise.resolve();
});
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Bob's baseline is still Bob's: a poll identical to his own first one has
// nothing to refresh. Had Alice's answer been recorded as the baseline,
// this would read as a 40-match jump and refresh the Matches view.
await tick();
expect(useMessageStore.getState().unreadCount).toBe(1);
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('never runs two summaries at once', async () => {
let release: (summary: SyncSummary) => void = () => undefined;
(appDataApi.syncSummary as jest.Mock).mockReset();
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => { release(baseline); await Promise.resolve(); });
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
@@ -90,3 +91,80 @@ describe("useDares", () => {
expect(result.current.error).toBe("offline");
});
});
describe("useDares polling cost", () => {
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
function mockCounting(active: () => SentDare[]) {
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
throw new Error(`unexpected path ${path}`);
});
return calls;
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(5_000); });
await flush();
};
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
let current: SentDare[] = [incoming];
const calls = mockCounting(() => current);
const { result } = renderHook(() => useDares({ isFocused: true }));
await flush();
expect(result.current.loading).toBe(false);
// The first pass is a full load: nothing is cached yet.
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
await tick();
await tick();
// Two quiet polls cost one request each, not four. The catalogue and the
// stats cannot move while the active set is byte-for-byte identical.
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
current = [{ ...incoming, status: "active" }];
await tick();
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
expect(result.current.active[0]?.status).toBe("active");
await tick();
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
});
it("issues nothing at all while the screen is not on top", async () => {
const calls = mockCounting(() => [incoming]);
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
({ isFocused }) => useDares({ isFocused }),
{ initialProps: { isFocused: false } },
);
await tick();
await tick();
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
rerender({ isFocused: true });
await flush();
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
});
it("never overlaps two polls", async () => {
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
return { dares: [], ...catalog, ...stats } as never;
});
renderHook(() => useDares({ isFocused: true }));
await flush();
await act(async () => { jest.advanceTimersByTime(20_000); });
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
});
});
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
});
});
describe('useDrawingSync focus gating', () => {
const handlers = {
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
const onLoadStrokes = jest.fn();
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
}), { initialProps: { isFocused: false } });
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
rerender({ isFocused: true });
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
rerender({ isFocused: false });
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
it('never polls over its own write in flight', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
let release: (state: unknown) => void = () => undefined;
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
}));
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
const pending = result.current.persistStrokes([stroke]);
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
// The write returns the authoritative state; reading over the top of it would
// only race a revision we are about to replace.
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
await act(async () => {
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
await pending;
});
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
id, match_id: 'match1', user_id, content: id,
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
} as any);
describe('useMessageSubscription API polling', () => {
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
messages,
removed_ids: options.removed ?? [],
server_time: options.server_time ?? 'cursor-2',
complete: false,
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
});
const listMessages = chatApi.listMessages as jest.Mock;
const markRead = chatApi.markRead as jest.Mock;
describe('useMessageSubscription incremental polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
listMessages.mockResolvedValue(snapshot([]));
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
it('fails closed without both match and authenticated user', () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
});
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValue(delta([]));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(1));
const cached = result.current.messages;
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(result.current.messages).toBe(cached);
expect(result.current.loading).toBe(false);
});
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
listMessages.mockResolvedValueOnce(snapshot([
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
]));
listMessages.mockResolvedValueOnce(delta([
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
], { removed: ['m2'] }));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
expect(result.current.messages[1]?.deleted_at).toBe('gone');
});
it('marks read only when the response holds an unread partner message', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
const { result } = mount();
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
expect(markRead).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(2);
});
it('issues no request at all while the chat is not focused', async () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
{ initialProps: { focused: false } },
);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).not.toHaveBeenCalled();
expect(markRead).not.toHaveBeenCalled();
rerender({ focused: true });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
rerender({ focused: false });
const callsAtBlur = listMessages.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
});
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
const { result } = mount({ onNewMessage });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
for (const expected of [2, 3, 4]) {
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
}
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
it('reads partner typing state out of the message response', async () => {
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
const { result } = mount();
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
listMessages.mockRejectedValueOnce(new Error('forbidden'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
it('prevents overlapping polls and stops on unmount', async () => {
let resolveFirst!: (value: unknown) => void;
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = mount();
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(1_500); });
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirst(snapshot([])); });
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(1);
});
it('restarts immediately, and without a stale cursor, for a different match', async () => {
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
{ initialProps: { matchId: 'match1' } },
);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
rerender({ matchId: 'match2' });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
});
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react-native';
import { usePolling } from '@/hooks/usePolling';
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const setForeground = async (value: boolean) => {
await act(async () => {
mockForeground.value = value;
for (const listener of [...mockListeners]) listener(value);
});
};
// The next run is scheduled once the previous one settles, so let pending
// promises land before and after moving the clock.
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async (ms: number) => {
await flush();
await act(async () => { jest.advanceTimersByTime(ms); });
await flush();
};
describe('usePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
});
afterEach(() => jest.useRealTimers());
it('runs once immediately and then on the interval', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('skips the leading run when asked to', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
expect(task).not.toHaveBeenCalled();
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
});
it('issues nothing while disabled and starts once enabled', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
initialProps: { enabled: false },
});
await tick(1_000);
expect(task).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(task).toHaveBeenCalledTimes(1);
});
it('stops while the app is backgrounded and catches up on return', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await setForeground(false);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(1);
await setForeground(true);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('never runs two tasks concurrently', async () => {
let release!: () => void;
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(500);
expect(task).toHaveBeenCalledTimes(1);
await act(async () => { release(); });
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
});
it('backs off after consecutive failures and recovers after a success', async () => {
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(399);
expect(task).toHaveBeenCalledTimes(2);
await tick(1);
expect(task).toHaveBeenCalledTimes(3);
task.mockImplementation(async () => undefined);
await tick(400);
expect(task).toHaveBeenCalledTimes(4);
await tick(100);
expect(task).toHaveBeenCalledTimes(5);
});
it('restarts immediately when the reset key changes', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
initialProps: { resetKey: 'a' },
});
expect(task).toHaveBeenCalledTimes(1);
rerender({ resetKey: 'b' });
expect(task).toHaveBeenCalledTimes(2);
await tick(50);
expect(task).toHaveBeenCalledTimes(2);
});
it('stops and unsubscribes on unmount', async () => {
const task = jest.fn(async () => undefined);
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
unmount();
expect(mockListeners.size).toBe(0);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(2);
});
});
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
it('stops polling and mirrors typing state supplied by the message poll', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
{ initialProps: { externalTyping: undefined as boolean | undefined } },
);
await act(async () => { await Promise.resolve(); });
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
rerender({ externalTyping: true });
expect(result.current.partnerTyping).toBe(true);
await act(async () => { jest.advanceTimersByTime(5_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
act(() => result.current.clearTypingIndicator());
expect(result.current.partnerTyping).toBe(false);
rerender({ externalTyping: false });
expect(result.current.partnerTyping).toBe(false);
});
});