mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
fix(native): prevent iOS black screen by avoiding provider tree remount on login (#14)
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import "@/global.css";
|
||||
import { ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
persistQueryClientRestore,
|
||||
persistQueryClientSubscribe,
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
@@ -255,33 +258,60 @@ function AppContent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children with PersistQueryClientProvider when scoped storage is ready,
|
||||
* otherwise uses plain QueryClientProvider. The key prop forces a remount when
|
||||
* the scope changes, triggering a restore from the new MMKV instance.
|
||||
* Always renders a single QueryClientProvider so the React tree is never torn
|
||||
* down. Cache persistence is managed imperatively: when scoped storage becomes
|
||||
* ready we restore from MMKV and subscribe to cache mutations; when the scope
|
||||
* changes (different server/user) we unsubscribe, restore from the new
|
||||
* partition, and re-subscribe.
|
||||
*/
|
||||
function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
// Re-render when scope changes so we switch between providers
|
||||
const [, setScopeVersion] = useState(0);
|
||||
const { data: session } = authClient.useSession();
|
||||
const instanceId = getCurrentInstanceId();
|
||||
const scopeReady = hasScopedStorage();
|
||||
|
||||
// When scope changes (via setStorageScope), force re-render
|
||||
useEffect(() => {
|
||||
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
|
||||
}, []);
|
||||
|
||||
if (scopeReady && instanceId && session?.user?.id) {
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
key={`${instanceId}_${session.user.id}`}
|
||||
client={queryClient}
|
||||
persistOptions={{ persister: queryPersister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
const scopeKey =
|
||||
scopeReady && instanceId && session?.user?.id
|
||||
? `${instanceId}_${session.user.id}`
|
||||
: null;
|
||||
|
||||
const prevScopeKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevScopeKeyRef.current;
|
||||
prevScopeKeyRef.current = scopeKey;
|
||||
|
||||
// Only clear when switching away from an active scope (user switch/logout).
|
||||
// Don't clear on initial activation (null → value) — preserve data
|
||||
// from queries that started before the scope was ready.
|
||||
// queryClient.clear() calls query.destroy() which silently cancels
|
||||
// in-flight fetches without notifying observers, permanently stalling
|
||||
// any queries that were mid-flight.
|
||||
if (prev != null && prev !== scopeKey) {
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
if (!scopeKey) return;
|
||||
|
||||
const options = { queryClient, persister: queryPersister };
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let aborted = false;
|
||||
|
||||
persistQueryClientRestore(options).then(() => {
|
||||
if (aborted) return;
|
||||
unsubscribe = persistQueryClientSubscribe(options);
|
||||
});
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [scopeKey]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
|
||||
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Link } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
@@ -55,20 +55,20 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
const titleHref = `/title/${item.id}` as `/title/${string}`;
|
||||
|
||||
return (
|
||||
<Link href={titleHref}>
|
||||
<Link.Trigger>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="mx-4 overflow-hidden rounded-2xl"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
height: 220,
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="mx-4 overflow-hidden rounded-2xl"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
height: 220,
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Link href={titleHref} asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint="Opens title details"
|
||||
@@ -158,11 +158,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||
import { Link } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
@@ -54,20 +54,20 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
const titleHref = `/title/${item.id}` as `/title/${string}`;
|
||||
|
||||
return (
|
||||
<Link href={titleHref}>
|
||||
<Link.Trigger>
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="mx-4 overflow-hidden rounded-2xl"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
height: 220,
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
className="mx-4 overflow-hidden rounded-2xl"
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
height: 220,
|
||||
borderCurve: "continuous",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Link href={titleHref} asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -118,11 +118,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ export function NativeTabBar() {
|
||||
name="(search)"
|
||||
role="search"
|
||||
disableTransparentOnScrollEdge
|
||||
/>
|
||||
>
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -267,7 +267,7 @@ export function PosterCard({
|
||||
<GestureDetector gesture={pressGesture}>
|
||||
<Animated.View style={[animatedStyle, { width }]}>
|
||||
<View>
|
||||
<Link href={titleHref}>
|
||||
<Link href={titleHref} asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -28,7 +28,10 @@ function isDeviceOnline(state: Network.NetworkState): boolean {
|
||||
}
|
||||
|
||||
function syncOnlineState(state: Network.NetworkState) {
|
||||
onlineManager.setOnline(isDeviceOnline(state));
|
||||
// Don't report offline if the server is confirmed reachable — expo-network
|
||||
// can report isInternetReachable: false on iOS Simulator or certain
|
||||
// network configs even when the server is actually accessible.
|
||||
onlineManager.setOnline(isDeviceOnline(state) || isReachable);
|
||||
}
|
||||
|
||||
function syncAppFocus(state: AppStateStatus) {
|
||||
@@ -65,6 +68,12 @@ export async function serverFetch(
|
||||
// endpoint itself returned a 4xx/5xx application error.
|
||||
setReachable(true);
|
||||
|
||||
// If React Query thinks we're offline but the server just responded,
|
||||
// correct the online state so paused queries can resume.
|
||||
if (!onlineManager.isOnline()) {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"apple": {
|
||||
"info": {
|
||||
"en-US": {
|
||||
"title": "Sofa - Movie & TV Tracker",
|
||||
"title": "Sofa - TV & Movie Tracker",
|
||||
"subtitle": "Companion app for Sofa servers",
|
||||
"promoText": "Sofa connects to your self-hosted server to track movies and TV shows. Log what you watch, manage your watchlist, and auto-track from Plex, Jellyfin, and Emby.",
|
||||
"description": "Sofa Mobile is the companion app for your self-hosted Sofa server. Track movies and TV shows, manage your watchlist, and pick up where you left off — all from your phone.\n\nA running Sofa server is required. Set one up at https://sofa.watch\n\nTRACK EVERYTHING YOU WATCH\nAdd movies and shows to your watchlist, mark them as watching or completed, and rate them on a 5-star scale. For TV shows, track progress down to individual episodes — mark a single episode, an entire season, or jump straight to where you left off.\n\nDISCOVER WHAT'S NEXT\nBrowse trending and popular titles, filter by genre, and get recommendations based on what's already in your library. Search across movies, TV shows, and people. Tap into any actor's filmography to find your next watch.\n\nSEE WHERE TO WATCH\nView streaming availability for any title so you know where to find it before you sit down.\n\nAUTO-TRACK FROM YOUR MEDIA SERVER\nConnect Plex, Jellyfin, or Emby to automatically log what you watch — no manual entry needed. Set up Sonarr and Radarr to sync your watchlist for automatic downloads.\n\nYOUR DATA, YOUR SERVER\nSofa is fully self-hosted. Your watch history, ratings, and library stay on your own server. No accounts with third parties, no data leaving your network unless you want it to.\n\nADMIN ON THE GO\nServer admins can check server health, manage registration settings, and see update notifications right from the app.\n\n• Watchlist, watching, and completed status tracking\n• Per-episode and per-season progress tracking\n• Continue Watching with next-episode info\n• Trending, popular, and personalized recommendations\n• Genre filtering\n• Full cast and filmography browsing\n• Streaming availability\n• Plex, Jellyfin, and Emby webhook integration\n• Sonarr and Radarr watchlist sync\n• Multi-user support with admin controls\n• Email/password and OIDC/SSO authentication",
|
||||
@@ -33,7 +33,7 @@
|
||||
"demoUsername": "apple-reviewer@sofa.watch",
|
||||
"demoPassword": "cupertino",
|
||||
"demoRequired": true,
|
||||
"notes": "Use https://app-store-demo.sofa.watch to login to the demo account."
|
||||
"notes": "Use https://app-store-demo.sofa.watch on the server URL screen to login to the demo account."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user