fix: refresh data on open page in background replace cached data

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent 24d04c1003
commit 090ed98233
15 changed files with 136 additions and 32 deletions

View File

@@ -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<string>(settings?.marlinServerUrl || "");

View File

@@ -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<string>(settings?.streamyStatsServerUrl || "");

View File

@@ -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
},
},
});

View File

@@ -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 {

View File

@@ -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<Settings>) => {
const updateUserConfiguration = async (

View File

@@ -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");

View File

@@ -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<boolean | undefined>(

View File

@@ -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<JellyseerrUser>(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<string[]>(JELLYSEERR_COOKIES);

View File

@@ -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();

View File

@@ -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(
<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: InvalidateQueryFilters<TTaggedQueryKey>,
options?: InvalidateOptions,
): Promise<void> => {
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]);
}

View File

@@ -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({

View File

@@ -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();

View File

@@ -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<void> => {
@@ -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 ({

View File

@@ -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<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [api] = useAtom(apiAtom);
const queryClient = useQueryClient();
const wasServerConnected = useRef<boolean | null>(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 (
<NetworkStatusContext.Provider
value={{ isConnected, serverConnected, loading, retryCheck }}

View File

@@ -0,0 +1,21 @@
import {
type InvalidateOptions,
type InvalidateQueryFilters,
onlineManager,
type QueryClient,
} from "@tanstack/react-query";
/**
* Invalidates queries only when online. When offline, the invalidation
* is skipped to preserve cached data for offline use.
*/
export function invalidateQueriesWhenOnline(
queryClient: QueryClient,
filters: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void> {
if (!onlineManager.isOnline()) {
return Promise.resolve();
}
return queryClient.invalidateQueries(filters, options);
}