Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 148 additions & 56 deletions AGENTS.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions app/(tabs)/(torrents)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@ export default function TorrentsScreen() {
initialLoadComplete,
} = useTorrents();
const { graceError, isPendingError } = useGracefulError(error);
const { isConnected, isLoading: serverIsLoading, isConnecting, connectToServer } = useServer();
const {
isConnected,
isLoading: serverIsLoading,
isConnecting,
isReconnecting,
connectToServer,
} = useServer();
const { colors, isDark } = useTheme();
const params = useLocalSearchParams<{
magnet?: string | string[];
Expand Down Expand Up @@ -1139,7 +1145,9 @@ export default function TorrentsScreen() {
if (
(isConnecting && !showAddModal) ||
(!initialLoadComplete && (serverIsLoading || !isConnected || isLoading)) ||
(initialLoadComplete && !showAddModal && (isRecoveringFromBackground || isPendingError))
(initialLoadComplete &&
!showAddModal &&
(isRecoveringFromBackground || isReconnecting || isPendingError))
) {
return (
<>
Expand Down
42 changes: 37 additions & 5 deletions app/(tabs)/(torrents)/torrent/[hash].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useServer } from '@/context/ServerContext';
import { useTheme } from '@/context/ThemeContext';
import { useToast } from '@/context/ToastContext';
import { useTorrents } from '@/context/TorrentContext';
import { isReconnectableError } from '@/hooks/useReactiveReconnect';
import {
isRealTracker,
getPseudoTrackerStates,
Expand All @@ -41,6 +42,7 @@ import {
} from '@/utils/trackers';
import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar';
import { AnimatedProgressBar } from '@/components/AnimatedProgressBar';
import { SkeletonTorrentDetail } from '@/components/SkeletonLoader';
import { SpeedGraph, computeSpeedGraphMax, niceGraphCeiling } from '@/components/SpeedGraph';
import { PieceMap } from '@/components/PieceMap';
import { InputModal, InputModalPreset } from '@/components/InputModal';
Expand Down Expand Up @@ -113,7 +115,7 @@ export default function TorrentDetail() {
const { hash } = useLocalSearchParams<{ hash: string }>();
const router = useRouter();
const navigation = useNavigation();
const { isConnected, isLoading } = useServer();
const { isConnected, isLoading, isReconnecting } = useServer();
const { features } = useApiFeatures();
const { colors, isDark } = useTheme();
const { showToast } = useToast();
Expand All @@ -137,6 +139,11 @@ export default function TorrentDetail() {
const [pieceStates, setPieceStates] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// True while a load failed on what looks like a dead session (an
// auto-reconnect is already in flight, per useReactiveReconnect's
// classification) — suppresses the error toast and keeps the skeleton up
// instead of falling through to "Torrent not found". See loadTorrentData.
const [sessionRecovering, setSessionRecovering] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [optimisticPaused, setOptimisticPaused] = useState<boolean | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null);
Expand Down Expand Up @@ -196,6 +203,22 @@ export default function TorrentDetail() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, isConnected]);

// A load that failed on a dead session (sessionRecovering) doesn't get a
// fresh attempt until the next 2s silentRefresh tick, since isConnected
// never actually changes (true the whole time). Retry immediately once
// the auto-reconnect that was already in flight resolves, rather than
// waiting on that tick.
const wasReconnectingRef = useRef(isReconnecting);
useEffect(() => {
const wasReconnecting = wasReconnectingRef.current;
wasReconnectingRef.current = isReconnecting;
if (wasReconnecting && !isReconnecting && sessionRecovering && isConnected) {
loadTorrentData();
}
// loadTorrentData isn't memoized — only re-run on the isReconnecting transition.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReconnecting, sessionRecovering, isConnected]);

// Encryption is a global qBittorrent setting, not per-torrent — fetch once
// per connection rather than on every poll tick.
useEffect(() => {
Expand Down Expand Up @@ -268,6 +291,7 @@ export default function TorrentDetail() {
handleTorrentGone();
return null;
}
setSessionRecovering(false);
setTorrent(next);
setProperties(props);
setTrackers(trackersData);
Expand All @@ -284,6 +308,15 @@ export default function TorrentDetail() {
handleTorrentGone();
return null;
}
// A dead session (auto-reconnect already in flight, per the same
// classification useReactiveReconnect uses) self-heals within a
// couple of seconds — show the skeleton instead of a toast that's
// stale the moment it appears, and "Torrent not found" for what's
// actually an auth problem.
if (isReconnectableError(getErrorMessage(error))) {
setSessionRecovering(true);
return null;
}
showToast(getErrorMessage(error), 'error');
return null;
} finally {
Expand Down Expand Up @@ -321,6 +354,7 @@ export default function TorrentDetail() {
handleTorrentGone();
return;
}
setSessionRecovering(false);
setTorrent(next);
setProperties(props);
setTrackers(trackersData);
Expand Down Expand Up @@ -1016,13 +1050,11 @@ export default function TorrentDetail() {
);
}

if (loading && !torrent) {
if ((loading || sessionRecovering || isReconnecting) && !torrent) {
return (
<>
<FocusAwareStatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
<View style={[styles.center, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
<SkeletonTorrentDetail />
</>
);
}
Expand Down
48 changes: 48 additions & 0 deletions components/SkeletonLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ export function SkeletonTorrentCard() {
);
}

/**
* Skeleton for the torrent detail screen — shown while a dead session is
* reconnecting and there's no torrent data to render yet (see
* app/(tabs)/(torrents)/torrent/[hash].tsx). Loosely shaped after that
* screen's hero card (name + state badge, progress bar, size line) plus one
* section below — an approximate placeholder, not a pixel-accurate clone,
* same spirit as SkeletonTorrentCard above.
*/
export function SkeletonTorrentDetail() {
const { colors } = useTheme();

return (
<View style={styles.detailContainer}>
<View style={[styles.detailCard, { backgroundColor: colors.surface }]}>
<View style={styles.heroHeaderRow}>
<SkeletonLoader width="65%" height={20} borderRadius={4} />
<SkeletonLoader width={70} height={20} borderRadius={9999} />
</View>
<SkeletonLoader width="100%" height={5} borderRadius={3} style={{ marginBottom: 8 }} />
<SkeletonLoader width="45%" height={13} borderRadius={4} />
</View>
<View style={[styles.detailCard, { backgroundColor: colors.surface }]}>
<SkeletonLoader width="30%" height={14} borderRadius={4} style={{ marginBottom: 10 }} />
<View style={styles.statsRow}>
<SkeletonLoader width="20%" height={12} borderRadius={4} />
<SkeletonLoader width="25%" height={12} borderRadius={4} />
<SkeletonLoader width="20%" height={12} borderRadius={4} />
</View>
</View>
</View>
);
}

const styles = StyleSheet.create({
container: {
overflow: 'hidden',
Expand All @@ -106,4 +139,19 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
},
detailContainer: {
paddingHorizontal: 12,
paddingTop: 10,
},
detailCard: {
padding: 12,
marginBottom: 12,
borderRadius: 12,
},
heroHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
});
13 changes: 13 additions & 0 deletions constants/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export interface ChangelogRelease {
sections?: ChangelogSection[];
}
export const CHANGELOG: ChangelogRelease[] = [
{
version: '3.8.43',
date: '2026-09-07',
sections: [
{
title: 'Bugs Fixed',
items: [
'Fixed the torrent list and torrent detail screen showing old data instead of a loading state while reconnecting after a long time in the background',
'Fixed an authentication error appearing when opening a torrent before the app finished reconnecting',
],
},
],
},
{
version: '3.8.42',
date: '2026-09-06',
Expand Down
15 changes: 15 additions & 0 deletions context/ServerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ interface ServerContextType {
* Null while disconnected.
*/
connectedAt: Date | null;
/**
* True while checkAndReconnect() (the reactive, error-driven auto-reconnect
* path) is in flight. Deliberately separate from `isConnecting` — that
* flag also covers a *manual* reconnect() and feeds `isLoading`, both of
* which are consumed in places that shouldn't change behavior for an
* automatic background recovery. Lets UI (e.g. the torrents list, torrent
* detail) show a soft "reconnecting" placeholder instead of stale data or
* a hard auth error during the window before an automatic reconnect
* resolves.
*/
isReconnecting: boolean;
connectToServer: (server: ServerConfig) => Promise<boolean>;
disconnect: () => Promise<void>;
/** Drop the remembered last server (e.g. after it was deleted). */
Expand All @@ -61,6 +72,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
const [activeEndpoint, setActiveEndpoint] = useState<ServerEndpointKind | null>(null);
const [initLoading, setInitLoading] = useState(true);
const [reconnecting, setReconnecting] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);

// Tracks the current connection's start time from isConnected transitions
// rather than from each individual setIsConnected call site, so every
Expand Down Expand Up @@ -272,6 +284,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
}

const run = async (): Promise<boolean> => {
setIsReconnecting(true);
if (!currentServer) {
setIsConnected(false);
setActiveEndpoint(null);
Expand Down Expand Up @@ -299,6 +312,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {

const id = currentServer?.id;
const promise = run().finally(() => {
setIsReconnecting(false);
if (checkAndReconnectPromiseRef.current?.id === id) {
checkAndReconnectPromiseRef.current = null;
}
Expand All @@ -321,6 +335,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
connectedAt,
isLoading,
isConnecting,
isReconnecting,
activeEndpoint,
connectToServer,
disconnect,
Expand Down
59 changes: 44 additions & 15 deletions context/TorrentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export function TorrentProvider({ children }: { children: ReactNode }) {
const [isRecoveringState, setIsRecoveringState] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);

// Tracks the most recent successful sync timestamp, and the value it was
// at when a recovery window started — so the "clear recovery" effect below
// can tell a genuinely new fetch apart from the stale timestamp that was
// already sitting there when recovery began. See that effect for why this
// distinction matters.
const dataUpdatedAtRef = useRef(0);
const recoveryBaselineRef = useRef(0);

const syncQueryFn = useCallback(async (): Promise<SyncState> => {
const version = syncVersionRef.current;
const currentRid = ridRef.current;
Expand Down Expand Up @@ -169,13 +177,35 @@ export function TorrentProvider({ children }: { children: ReactNode }) {
}
}, [dataUpdatedAt, initialLoadComplete]);

// Clear recovery state after successful fetch
// Keep the latest successful-sync timestamp available to the AppState
// handler (below) without adding it to that effect's own deps.
useEffect(() => {
dataUpdatedAtRef.current = dataUpdatedAt;
}, [dataUpdatedAt]);

// Clear recovery state only once a fetch *newer than the one already
// sitting there when recovery started* actually lands. `dataUpdatedAt`
// being merely nonzero isn't enough — it holds the last successful sync
// from potentially hours ago, so comparing against 0 cleared this on the
// very next render, before the foreground re-sync had a chance to run.
useEffect(() => {
if (dataUpdatedAt > 0 && isRecoveringState) {
if (isRecoveringState && dataUpdatedAt > recoveryBaselineRef.current) {
setIsRecoveringState(false);
}
}, [dataUpdatedAt, isRecoveringState]);

// Safety cap: if a foreground recovery never resolves (e.g. the refetch
// below got deduped into an already-in-flight poll and no new data ever
// lands), don't leave the user on a skeleton forever. The normal exits are
// already bounded — a successful re-sync clears the flag above, and a
// genuine failure flips isConnected false, which routes to the
// not-connected screen regardless.
useEffect(() => {
if (!isRecoveringState) return undefined;
const timeout = setTimeout(() => setIsRecoveringState(false), 15000);
return () => clearTimeout(timeout);
}, [isRecoveringState]);

// Reset sync state when disconnected
useEffect(() => {
if (!isConnected) {
Expand Down Expand Up @@ -204,6 +234,10 @@ export function TorrentProvider({ children }: { children: ReactNode }) {
lastActiveTime.current = Date.now();

if (isConnected) {
// Baseline against the last successful sync *before* flipping the
// flag, so the clear effect above can tell a genuinely new fetch
// apart from the stale timestamp already sitting there.
recoveryBaselineRef.current = dataUpdatedAtRef.current;
setIsRecoveringState(true);

// Deliberately NOT eagerly reconnecting here. checkAndReconnect
Expand All @@ -226,20 +260,15 @@ export function TorrentProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['torrents'] }).finally(() => resolve());
});
});
// Only clear the recovering flag once the re-sync actually
// succeeded. invalidateQueries settles regardless of whether the
// refetch itself failed (e.g. the qBittorrent session died while
// backgrounded), so clearing unconditionally here briefly exposed
// a real "Authentication failed" error before the reactive
// reconnect effect (which watches queryError, above) had a chance
// to re-login and succeed. Leaving it set on failure means the
// "clear after successful fetch" effect above is what turns it
// off, once a subsequent poll or reconnect actually goes through —
// and isConnected flipping false (genuine disconnect) still falls
// Deliberately not clearing the flag here — invalidateQueries
// settles regardless of whether the refetch itself failed (e.g.
// the qBittorrent session died while backgrounded). The baselined
// clear effect above is what turns recovery off, once a fetch
// newer than the pre-recovery baseline actually lands (either
// this re-sync succeeding outright, or a later poll succeeding
// after the reactive reconnect effect re-logs in) — and
// isConnected flipping false (genuine disconnect) still falls
// through to the normal not-connected screen regardless.
if (queryClient.getQueryState(['torrents'])?.status !== 'error') {
setIsRecoveringState(false);
}
}
} else if (nextAppState === 'background') {
lastActiveTime.current = Date.now();
Expand Down
32 changes: 14 additions & 18 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,20 @@ speculative entries, no "while I'm here" additions, no entry per edit. When the
user does ask, edit it as the **last step before the commit** — one pass covering
everything in the session, not one entry per change.

### Which entry to edit depends on the branch

The app ships OTA through EAS Update, so the two branches track versions
differently:

- **On `develop`** — the top entry is a placeholder whose version ends in
`.TESTFLIGHT` (`X.Y.TESTFLIGHT` — the `X.Y` moves with the release train, so
match on the **suffix**, never on a specific number). **Append** your line to
the matching section in that entry. Don't create an entry, don't renumber the
placeholder, don't invent a version. If the top entry *isn't* a `.TESTFLIGHT`
placeholder, stop and ask which entry to use rather than guessing.
- **On `main`** — versions are real. Compare `package.json`'s `version` to
`CHANGELOG[0].version`:
- **They DIFFER** (changelog ahead) → an unreleased entry is already open.
**Append** to it. Don't create an entry, don't change its version.
- **They're EQUAL** → the top entry is already released. Add ONE new entry at
the top with a **patch bump only** and today's date. **Don't touch
`package.json`** — the release process owns the app version.
### Which entry to edit

All work now branches off `main` and returns by PR (`develop` and `preview` are
retired — see AGENTS.md). Versions in the changelog are therefore always real;
there is no `.TESTFLIGHT` placeholder flow any more.

Compare `package.json`'s `version` to `CHANGELOG[0].version`:

- **They DIFFER** (changelog ahead) → an unreleased entry is already open.
**Append** to it. Don't create an entry, don't change its version.
- **They're EQUAL** → the top entry is already released. Add ONE new entry at
the top with a **patch bump only** and today's date. **Don't touch
`package.json`** — the release process owns the app version, unless the user
explicitly asks you to bump it.

Patch numbers are always two digits (`3.8.05`, never `3.8.5`).

Expand Down
Loading
Loading