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

+20 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
const isFocused = useIsFocused();
// Typing indicator hook
// Each hook needs a value the other produces: the message poll reports typing
// state, and an arriving message clears the indicator. The ref breaks the cycle.
const clearTypingRef = useRef<() => void>(() => undefined);
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
// Message subscription hook
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: handleNewMessage,
isFocused,
});
// Typing indicator hook, reading the state the message poll already fetched
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
externalTyping: syncedTyping,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
useEffect(() => {
clearTypingRef.current = clearTypingIndicator;
}, [clearTypingIndicator]);
// Message actions hook for deletion and reporting
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
return () => setActiveMatchId(null);
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall";
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
];
export function DaresScreen() {
const dares = useDares();
const isFocused = useIsFocused();
const dares = useDares({ isFocused });
const [tab, setTab] = useState<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import { usePolling } from "../../../hooks/usePolling";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresConfig {
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. Polling stops when the screen is not
* on top; `usePolling` additionally stops it when the app backgrounds.
*/
isFocused?: boolean;
}
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
/**
* Identity of the active set as the user experiences it. History, stats and the
* remaining-send allowance only move when a dare enters or leaves this set or
* changes status, so this is the signal that says the slower endpoints are worth
* re-reading.
*/
const activeSignature = (dares: SentDare[]): string =>
dares
.map(dare => `${dare.id}:${dare.status}`)
.sort()
.join("|");
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const signatureRef = useRef<string | null>(null);
const refresh = useCallback(async () => {
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/**
* `full` reads everything; the polling path reads only the active list and
* follows up on the rest when that list actually changed. A failure updates
* the UI's error state either way, but is only rethrown for the poll path
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
* while pull-to-refresh and post-mutation callers already surface the error
* themselves and must not turn it into an unhandled rejection.
*/
const load = useCallback(async (full: boolean, throwOnError = false) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
const activeResult = await daresApi.list("active");
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
const signature = activeSignature(activeResult.dares);
const changed = signature !== signatureRef.current;
signatureRef.current = signature;
if (full || changed) {
const [historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
}
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
if (throwOnError) throw cause;
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
const refresh = useCallback(() => load(true), [load]);
const poll = useCallback(() => load(false, true), [load]);
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
@@ -8,6 +8,7 @@ import {
useWindowDimensions,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user } = useAuthStore();
const coupleId = coupleIdParam || user?.couple_id;
const isFocused = useIsFocused();
const { width: screenWidth } = useWindowDimensions();
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
const sync = useDrawingSync({
coupleId: coupleId!,
userId: user?.id,
isFocused,
onStrokeStart: handleStrokeStartFromPartner,
onStrokeContinue: handleStrokeContinueFromPartner,
onStrokeEnd: handleStrokeEndFromPartner,
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import { usePolling } from '../../../hooks/usePolling';
import type { StrokeSegment, StrokePoint } from '../types';
import { updateWidget } from '../utils/widgetBridge';
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
const POLL_INTERVAL_MS = 750;
interface UseDrawingSyncConfig {
coupleId: string;
userId: string | undefined;
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
onUndo: (strokeId: string) => void;
onRedo: (stroke: StrokeSegment) => void;
onLoadStrokes: (strokes: StrokeSegment[]) => void;
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. A canvas nobody is looking at polls
* nothing; `usePolling` also stops it when the app goes to the background.
*/
isFocused?: boolean;
}
interface UseDrawingSyncReturn {
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const { coupleId, userId, onLoadStrokes } = config;
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
const revisionRef = useRef(0);
const baselineRef = useRef<StrokeSegment[]>([]);
const workingRef = useRef<StrokeSegment[]>([]);
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
}, [coupleId, acceptState]);
useEffect(() => {
if (!coupleId || !userId) return;
let cancelled = false;
const poll = async () => {
if (pendingWritesRef.current > 0) return;
try {
const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); }
};
const timer = setInterval(() => void poll(), 750);
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
// Our own writes already return the authoritative state, so reading over the top
// of one in flight would only race it back to a revision we are about to replace.
const poll = useCallback(async () => {
if (pendingWritesRef.current > 0) return;
const state = await appDataApi.getLiveDraw();
if (state.revision > revisionRef.current) acceptState(state);
}, [acceptState]);
// `leading: false` because the mount effect above already read the initial state;
// usePolling adds the overlap guard, backoff and background pause the raw
// interval had none of.
usePolling(poll, {
intervalMs: POLL_INTERVAL_MS,
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
leading: false,
resetKey: `${coupleId}:${userId ?? ''}`,
});
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
// This answer changed pack progress and the answer history. Marking their
// caches stale costs no request: whichever screen the user opens next
// reloads once, and the ones they do not open stay quiet.
usePacksStore.getState().invalidatePacks();
useResponsesStore.getState().invalidateResponses();
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.