diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx index 4857a928..10be4af5 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -16,6 +15,7 @@ import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import DisabledSetting from "@/components/settings/DisabledSetting"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; export default function page() { @@ -26,7 +26,7 @@ export default function page() { const insets = useSafeAreaInsets(); const { settings, updateSettings, pluginSettings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const [value, setValue] = useState(settings?.marlinServerUrl || ""); diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 6b8fc86c..697db6c4 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -15,6 +14,7 @@ import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; export default function page() { @@ -28,7 +28,7 @@ export default function page() { pluginSettings, refreshStreamyfinPluginSettings, } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); // Local state for all editable fields const [url, setUrl] = useState(settings?.streamyStatsServerUrl || ""); diff --git a/app/_layout.tsx b/app/_layout.tsx index 788a4f6c..13c0b57d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,9 +1,10 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import NetInfo from "@react-native-community/netinfo"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; -import { QueryClient } from "@tanstack/react-query"; +import { onlineManager, QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; @@ -187,11 +188,29 @@ export default function RootLayout() { ); } +// Set up online manager for network-aware query behavior +onlineManager.setEventListener((setOnline) => { + return NetInfo.addEventListener((state) => { + setOnline(!!state.isConnected); + }); +}); + const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 30000, // 30 seconds - data is fresh - gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence + staleTime: 0, // Always stale - triggers background refetch on mount + gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline + networkMode: "offlineFirst", // Return cache first, refetch if online + refetchOnMount: true, // Refetch when component mounts + refetchOnReconnect: true, // Refetch when network reconnects + refetchOnWindowFocus: false, // Not needed for mobile + retry: (failureCount) => { + if (!onlineManager.isOnline()) return false; + return failureCount < 3; + }, + }, + mutations: { + networkMode: "online", // Only run mutations when online }, }, }); diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 5453f32a..47dedf12 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -1,5 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { t } from "i18next"; @@ -12,6 +11,7 @@ import { } from "react-native"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useDownload } from "@/providers/DownloadProvider"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { JobStatus } from "@/providers/Downloads/types"; @@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const { cancelDownload } = useDownload(); const router = useRouter(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const handleDelete = async (id: string) => { try { diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx index c6c7856e..67bcf8ea 100644 --- a/components/settings/MediaContext.tsx +++ b/components/settings/MediaContext.tsx @@ -4,9 +4,10 @@ import type { UserDto, } from "@jellyfin/sdk/lib/generated-client/models"; import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { createContext, type ReactNode, useContext, useEffect } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom } from "@/providers/JellyfinProvider"; import { type Settings, useSettings } from "@/utils/atoms/settings"; @@ -30,7 +31,7 @@ export const useMedia = () => { export const MediaProvider = ({ children }: { children: ReactNode }) => { const { settings, updateSettings } = useSettings(); const api = useAtomValue(apiAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const updateSetingsWrapper = (update: Partial) => { const updateUserConfiguration = async ( diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 0c4c73cc..4a02c9a2 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; @@ -6,6 +6,7 @@ import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; import { useHaptic } from "@/hooks/useHaptic"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { clearCache, clearPermanentDownloads, @@ -18,7 +19,7 @@ import { ListItem } from "../list/ListItem"; export const StorageSettings = () => { const { deleteAllFiles, appSizeUsage } = useDownload(); const { t } = useTranslation(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); diff --git a/hooks/useFavorite.ts b/hooks/useFavorite.ts index 9e9cab94..a07afe03 100644 --- a/hooks/useFavorite.ts +++ b/hooks/useFavorite.ts @@ -1,12 +1,13 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; export const useFavorite = (item: BaseItemDto) => { - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [isFavorite, setIsFavorite] = useState( diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index bba24fd4..4ae918d8 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -10,10 +10,10 @@ import type { } from "@/utils/jellyseerr/server/models/Search"; import { storage } from "@/utils/mmkv"; import "@/augmentations"; -import { useQueryClient } from "@tanstack/react-query"; import { t } from "i18next"; import { useCallback, useMemo } from "react"; import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useSettings } from "@/utils/atoms/settings"; import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { @@ -436,7 +436,7 @@ const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)); export const useJellyseerr = () => { const { settings, updateSettings } = useSettings(); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const jellyseerrApi = useMemo(() => { const cookies = storage.get(JELLYSEERR_COOKIES); diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index e21687fa..a68439f3 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -1,12 +1,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useHaptic } from "./useHaptic"; import { usePlaybackManager } from "./usePlaybackManager"; import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache"; export const useMarkAsPlayed = (items: BaseItemDto[]) => { - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const lightHapticFeedback = useHaptic("light"); const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); diff --git a/hooks/useNetworkAwareQueryClient.ts b/hooks/useNetworkAwareQueryClient.ts new file mode 100644 index 00000000..b0b2314b --- /dev/null +++ b/hooks/useNetworkAwareQueryClient.ts @@ -0,0 +1,47 @@ +import type { + InvalidateOptions, + InvalidateQueryFilters, + QueryClient, + QueryKey, +} from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { invalidateQueriesWhenOnline } from "@/utils/query/networkAwareInvalidate"; + +type NetworkAwareQueryClient = QueryClient & { + forceInvalidateQueries: QueryClient["invalidateQueries"]; +}; + +/** + * Returns a queryClient wrapper with network-aware invalidation. + * Use this instead of useQueryClient when you need to invalidate queries. + * + * - invalidateQueries: Only invalidates when online (preserves offline cache) + * - forceInvalidateQueries: Always invalidates (use sparingly) + */ +export function useNetworkAwareQueryClient(): NetworkAwareQueryClient { + const queryClient = useQueryClient(); + + const networkAwareInvalidate = useCallback( + ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise => { + if (!filters) { + return Promise.resolve(); + } + return invalidateQueriesWhenOnline(queryClient, filters, options); + }, + [queryClient], + ); + + return useMemo(() => { + // Create a proxy-like object that inherits from queryClient + // but overrides invalidateQueries + const wrapped = Object.create(queryClient) as NetworkAwareQueryClient; + wrapped.invalidateQueries = networkAwareInvalidate; + wrapped.forceInvalidateQueries = + queryClient.invalidateQueries.bind(queryClient); + return wrapped; + }, [queryClient, networkAwareInvalidate]); +} diff --git a/hooks/usePlaylistMutations.ts b/hooks/usePlaylistMutations.ts index 7dca1f64..dd3f13d6 100644 --- a/hooks/usePlaylistMutations.ts +++ b/hooks/usePlaylistMutations.ts @@ -1,8 +1,9 @@ import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useTranslation } from "react-i18next"; import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; /** @@ -11,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; export const useCreatePlaylist = () => { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { t } = useTranslation(); const mutation = useMutation({ @@ -58,7 +59,7 @@ export const useCreatePlaylist = () => { export const useAddToPlaylist = () => { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { t } = useTranslation(); const mutation = useMutation({ @@ -108,7 +109,7 @@ export const useAddToPlaylist = () => { */ export const useRemoveFromPlaylist = () => { const api = useAtomValue(apiAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { t } = useTranslation(); const mutation = useMutation({ @@ -160,7 +161,7 @@ export const useRemoveFromPlaylist = () => { */ export const useDeletePlaylist = () => { const api = useAtomValue(apiAtom); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { t } = useTranslation(); const mutation = useMutation({ diff --git a/hooks/useRevalidatePlaybackProgressCache.ts b/hooks/useRevalidatePlaybackProgressCache.ts index e10202f3..c8c310fc 100644 --- a/hooks/useRevalidatePlaybackProgressCache.ts +++ b/hooks/useRevalidatePlaybackProgressCache.ts @@ -1,4 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useDownload } from "@/providers/DownloadProvider"; import { useTwoWaySync } from "./useTwoWaySync"; @@ -6,7 +6,7 @@ import { useTwoWaySync } from "./useTwoWaySync"; * useRevalidatePlaybackProgressCache invalidates queries related to playback progress. */ export function useInvalidatePlaybackProgressCache() { - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const { getDownloadedItems } = useDownload(); const { syncPlaybackState } = useTwoWaySync(); diff --git a/hooks/useWatchlistMutations.ts b/hooks/useWatchlistMutations.ts index ad15c0ac..e3e39ef9 100644 --- a/hooks/useWatchlistMutations.ts +++ b/hooks/useWatchlistMutations.ts @@ -1,7 +1,8 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useCallback } from "react"; import { toast } from "sonner-native"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; @@ -17,7 +18,7 @@ import type { export const useCreateWatchlist = () => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const mutation = useMutation({ mutationFn: async ( @@ -58,7 +59,7 @@ export const useCreateWatchlist = () => { export const useUpdateWatchlist = () => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const mutation = useMutation({ mutationFn: async ({ @@ -106,7 +107,7 @@ export const useUpdateWatchlist = () => { export const useDeleteWatchlist = () => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const mutation = useMutation({ mutationFn: async (watchlistId: number): Promise => { @@ -147,7 +148,7 @@ export const useDeleteWatchlist = () => { export const useAddToWatchlist = () => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const mutation = useMutation({ mutationFn: async ({ @@ -205,7 +206,7 @@ export const useAddToWatchlist = () => { export const useRemoveFromWatchlist = () => { const api = useAtomValue(apiAtom); const { settings } = useSettings(); - const queryClient = useQueryClient(); + const queryClient = useNetworkAwareQueryClient(); const mutation = useMutation({ mutationFn: async ({ diff --git a/providers/NetworkStatusProvider.tsx b/providers/NetworkStatusProvider.tsx index b9bea264..25b4fd62 100644 --- a/providers/NetworkStatusProvider.tsx +++ b/providers/NetworkStatusProvider.tsx @@ -1,4 +1,5 @@ import NetInfo from "@react-native-community/netinfo"; +import { useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { createContext, @@ -6,6 +7,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from "react"; import { apiAtom } from "@/providers/JellyfinProvider"; @@ -37,6 +39,8 @@ export function NetworkStatusProvider({ children }: { children: ReactNode }) { const [serverConnected, setServerConnected] = useState(null); const [loading, setLoading] = useState(false); const [api] = useAtom(apiAtom); + const queryClient = useQueryClient(); + const wasServerConnected = useRef(null); const validateConnection = useCallback(async () => { if (!api?.basePath) return false; @@ -73,6 +77,14 @@ export function NetworkStatusProvider({ children }: { children: ReactNode }) { return () => unsubscribe(); }, [validateConnection]); + // Refetch active queries when server becomes reachable + useEffect(() => { + if (serverConnected && wasServerConnected.current === false) { + queryClient.refetchQueries({ type: "active" }); + } + wasServerConnected.current = serverConnected; + }, [serverConnected, queryClient]); + return ( { + if (!onlineManager.isOnline()) { + return Promise.resolve(); + } + return queryClient.invalidateQueries(filters, options); +}