mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
9957fa43f2
commit
c7ee8a4851
81 files changed
+4456
-1400
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { chatApi } from '../lib/chatApi';
|
||||
import { chatApi, type MessagesPage } from '../lib/chatApi';
|
||||
import { usePolling } from './usePolling';
|
||||
import type { Message } from '../features/chat/types';
|
||||
|
||||
export interface UseMessageSubscriptionConfig {
|
||||
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
|
||||
userId: string | undefined;
|
||||
onNewMessage?: () => void;
|
||||
pollInterval?: number;
|
||||
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMessageSubscriptionReturn {
|
||||
messages: Message[];
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
loading: boolean;
|
||||
isFocusedRef: React.MutableRefObject<boolean>;
|
||||
/** Partner typing state folded into the message poll; undefined until the server reports it. */
|
||||
partnerTyping: boolean | undefined;
|
||||
/** Fetches the next delta now, e.g. straight after sending. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
|
||||
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const timeOf = (message: Message): number => {
|
||||
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
/** Newest first, matching the order the API returns a snapshot in. */
|
||||
const byNewest = (a: Message, b: Message): number => {
|
||||
const delta = timeOf(b) - timeOf(a);
|
||||
if (delta !== 0) return delta;
|
||||
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the previous array unchanged when a delta carries nothing, so an idle
|
||||
* poll re-renders neither this hook's consumers nor the message list.
|
||||
*/
|
||||
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
|
||||
if (page.complete !== false) return page.messages;
|
||||
const removed = page.removed_ids ?? [];
|
||||
if (page.messages.length === 0 && removed.length === 0) return previous;
|
||||
|
||||
const byId = new Map(previous.map(message => [message.id, message]));
|
||||
for (const message of page.messages) byId.set(message.id, message);
|
||||
for (const id of removed) byId.delete(id);
|
||||
return [...byId.values()].sort(byNewest);
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
|
||||
*
|
||||
* Each poll asks only for what changed since the cursor the server issued last
|
||||
* time, so an idle conversation costs an all-but-empty response instead of the
|
||||
* five hundred messages the screen already has. Read receipts are written only
|
||||
* when the response actually contains an unread message from the partner, which
|
||||
* on an idle chat is never.
|
||||
*/
|
||||
export const useMessageSubscription = ({
|
||||
matchId,
|
||||
userId,
|
||||
onNewMessage,
|
||||
pollInterval = 2_000,
|
||||
isFocused = true,
|
||||
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isFocusedRef = useRef(false);
|
||||
const [loading, setLoading] = useState(Boolean(matchId && userId));
|
||||
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
|
||||
const sinceRef = useRef<string | null>(null);
|
||||
const knownIdsRef = useRef<Set<string>>(new Set());
|
||||
const fetchingRef = useRef(false);
|
||||
const loadingRef = useRef(true);
|
||||
const notifyRef = useRef(onNewMessage);
|
||||
notifyRef.current = onNewMessage;
|
||||
|
||||
// A different conversation shares none of this one's state, and the poll below
|
||||
// restarts on the same key so the new one loads immediately.
|
||||
useEffect(() => {
|
||||
sinceRef.current = null;
|
||||
knownIdsRef.current = new Set();
|
||||
loadingRef.current = Boolean(matchId && userId);
|
||||
setMessages([]);
|
||||
setPartnerTyping(undefined);
|
||||
setLoading(loadingRef.current);
|
||||
}, [matchId, userId]);
|
||||
|
||||
const refresh = useCallback(async (initial = false) => {
|
||||
if (!matchId || !userId || fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!matchId || !userId) return;
|
||||
const since = sinceRef.current;
|
||||
try {
|
||||
const response = await chatApi.listMessages(matchId);
|
||||
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
knownIdsRef.current = new Set(response.messages.map(message => message.id));
|
||||
setMessages(response.messages);
|
||||
if (isFocusedRef.current) await chatApi.markRead(matchId);
|
||||
if (partnerArrived) onNewMessage?.();
|
||||
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
|
||||
// Without a cursor the server cannot answer with deltas, so keep asking
|
||||
// for snapshots rather than silently freezing on a stale page.
|
||||
sinceRef.current = page.server_time ?? null;
|
||||
if (page.typing) setPartnerTyping(page.typing.typing);
|
||||
|
||||
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
for (const message of page.messages) knownIdsRef.current.add(message.id);
|
||||
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
|
||||
setMessages(previous => mergeMessagePage(previous, page));
|
||||
|
||||
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
|
||||
await chatApi.markRead(matchId);
|
||||
}
|
||||
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (initial) setLoading(false);
|
||||
if (loadingRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [matchId, userId, onNewMessage]);
|
||||
}, [matchId, userId, isFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
knownIdsRef.current = new Set();
|
||||
void refresh(true).catch(() => undefined);
|
||||
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
|
||||
return () => clearInterval(timer);
|
||||
}, [matchId, userId, pollInterval, refresh]);
|
||||
usePolling(refresh, {
|
||||
intervalMs: pollInterval,
|
||||
enabled: Boolean(matchId && userId && isFocused),
|
||||
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
|
||||
});
|
||||
|
||||
return { messages, setMessages, loading, isFocusedRef };
|
||||
return { messages, setMessages, loading, partnerTyping, refresh };
|
||||
};
|
||||
Reference in new issue
Block a user