mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 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,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.
|
||||
|
||||
Reference in new issue
Block a user