fix(connection): strengthen sidebar recovery after reconnect (#1160) (#1190)

This commit is contained in:
cucadmuh
2026-06-25 19:52:40 +03:00
committed by GitHub
parent 00512df207
commit cea814e993
8 changed files with 83 additions and 22 deletions
+6
View File
@@ -297,6 +297,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
### Sidebar — offline nav gating after manual reconnect Retry
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
## Under the Hood
### ESLint setup and a strict lint pass over the frontend
+1
View File
@@ -176,6 +176,7 @@ const CONTRIBUTOR_ENTRIES = [
'CI: ESLint strict workflow and path-aware ci-ok merge gate (PR #1170)',
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (230 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
],
},
{
+21
View File
@@ -19,10 +19,12 @@ vi.mock('@/utils/perf/perfFlags', () => ({
import { pingWithCredentialsForProfile } from '@/api/subsonic';
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/utils/network/activeServerReachability';
import { useConnectionStatus } from './useConnectionStatus';
beforeEach(() => {
resetAuthStore();
resetActiveServerConnectionSnapshot();
invalidateReachableEndpointCache();
useDevOfflineBrowseStore.getState().setForceOffline(false);
vi.mocked(pingWithCredentialsForProfile).mockReset();
@@ -187,3 +189,22 @@ describe('useConnectionStatus DEV offline toggle', () => {
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
});
});
describe('useConnectionStatus shared status', () => {
it('keeps all hook instances in sync when connection status changes', () => {
const shell = renderHook(() => useConnectionStatus());
const sidebar = renderHook(() => useConnectionStatus());
act(() => {
setConnectionStatus('disconnected');
});
expect(shell.result.current.status).toBe('disconnected');
expect(sidebar.result.current.status).toBe('disconnected');
act(() => {
setConnectionStatus('connected');
});
expect(shell.result.current.status).toBe('connected');
expect(sidebar.result.current.status).toBe('connected');
});
});
+23 -18
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react';
import { useAuthStore } from '../store/authStore';
import { scheduleInstantMixProbeForServer } from '../api/subsonic';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
@@ -8,7 +8,13 @@ import {
isLanUrl,
type ServerEndpointKind,
} from '../utils/server/serverEndpoint';
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
import {
getConnectionStatus,
setActiveServerReachable,
setConnectionStatus,
subscribeConnectionStatus,
type ConnectionStatus,
} from '../utils/network/activeServerReachability';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
isDevOfflineBrowseForced,
@@ -17,13 +23,12 @@ import {
// Backward-compatible re-export for call sites that still import from the hook.
export { isLanUrl };
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
export type { ConnectionStatus };
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const [status, setStatus] = useState<ConnectionStatus>('checking');
const status = useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus, getConnectionStatus);
const [isRetrying, setIsRetrying] = useState(false);
// Tracks the kind of endpoint the last successful probe answered on so the
// badge reflects the *active* connection, not just whatever the user typed
@@ -36,20 +41,20 @@ export function useConnectionStatus() {
const check = useCallback(async () => {
if (isDevOfflineBrowseForced()) {
setActiveServerReachable(false);
setStatus('disconnected');
setConnectionStatus('disconnected');
return;
}
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setActiveServerReachable(false);
setStatus('disconnected');
setConnectionStatus('disconnected');
return;
}
if (!navigator.onLine) {
setActiveServerReachable(false);
setStatus('disconnected');
setConnectionStatus('disconnected');
return;
}
@@ -74,7 +79,7 @@ export function useConnectionStatus() {
setActiveEndpointKind(null);
}
setActiveServerReachable(probe.ok);
setStatus(probe.ok ? 'connected' : 'disconnected');
setConnectionStatus(probe.ok ? 'connected' : 'disconnected');
}, []);
const retry = useCallback(async () => {
@@ -97,9 +102,7 @@ export function useConnectionStatus() {
prevDevForceOfflineRef.current = devForceOffline;
if (devForceOffline) {
setActiveServerReachable(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setStatus('disconnected');
setConnectionStatus('disconnected');
}
return;
}
@@ -109,10 +112,12 @@ export function useConnectionStatus() {
if (devForceOffline) {
setActiveServerReachable(false);
setStatus('disconnected');
setConnectionStatus('disconnected');
return;
}
if (!perfFlags.disableBackgroundPolling) {
// React Compiler set-state-in-effect rule: probe after DEV offline toggle clears.
// eslint-disable-next-line react-hooks/set-state-in-effect
void check();
}
}, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
@@ -125,15 +130,15 @@ export function useConnectionStatus() {
}
if (isDevOfflineBrowseForced()) {
setActiveServerReachable(false);
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setStatus('disconnected');
setConnectionStatus('disconnected');
} else {
setActiveServerReachable(true);
setStatus('connected');
setConnectionStatus('connected');
}
return;
}
// React Compiler set-state-in-effect rule: initial probe + polling interval on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
check();
intervalRef.current = setInterval(check, 120_000);
@@ -146,7 +151,7 @@ export function useConnectionStatus() {
};
const handleOffline = () => {
setActiveServerReachable(false);
setStatus('disconnected');
setConnectionStatus('disconnected');
};
window.addEventListener('online', handleOnline);
@@ -4,13 +4,14 @@ import {
getActiveServerReachable,
isActiveServerReachable,
onActiveServerBecameReachable,
resetActiveServerConnectionSnapshot,
setActiveServerReachable,
} from './activeServerReachability';
describe('activeServerReachability', () => {
beforeEach(() => {
useDevOfflineBrowseStore.setState({ forceOffline: false });
setActiveServerReachable(null);
resetActiveServerConnectionSnapshot();
});
it('isActiveServerReachable requires an explicit successful probe', () => {
@@ -1,13 +1,17 @@
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
/**
* Active-server reachability snapshot maintained by `useConnectionStatus`.
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
* network attempts while the browser or Subsonic endpoint is down.
*/
let activeServerReachable: boolean | null = null;
let connectionStatus: ConnectionStatus = 'checking';
const reachableListeners = new Set<() => void>();
const connectionStatusListeners = new Set<() => void>();
/** Fires when the active server transitions to reachable (`false`/`null` → `true`). */
export function onActiveServerBecameReachable(listener: () => void): () => void {
@@ -27,6 +31,28 @@ export function getActiveServerReachable(): boolean | null {
return activeServerReachable;
}
/** Shared across all `useConnectionStatus` hook instances (manual retry, polling). */
export function getConnectionStatus(): ConnectionStatus {
return connectionStatus;
}
export function setConnectionStatus(next: ConnectionStatus): void {
if (connectionStatus === next) return;
connectionStatus = next;
for (const listener of connectionStatusListeners) listener();
}
export function subscribeConnectionStatus(listener: () => void): () => void {
connectionStatusListeners.add(listener);
return () => connectionStatusListeners.delete(listener);
}
/** Test helper — resets module-level connection snapshot. */
export function resetActiveServerConnectionSnapshot(): void {
activeServerReachable = null;
connectionStatus = 'checking';
}
/** True only when the browser is online and the last active-server probe succeeded. */
export function isActiveServerReachable(): boolean {
if (isDevOfflineBrowseForced()) return false;
@@ -1,11 +1,13 @@
import { renderHook, act } from '@testing-library/react';
import { describe, expect, it, beforeEach } from 'vitest';
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
import { resetActiveServerConnectionSnapshot } from '../network/activeServerReachability';
import { useOfflineBrowseActive } from './offlineBrowseMode';
describe('useOfflineBrowseActive', () => {
beforeEach(() => {
useDevOfflineBrowseStore.setState({ forceOffline: false });
resetActiveServerConnectionSnapshot();
});
it('enables offline browse when DEV force-offline is set', () => {
+2 -3
View File
@@ -18,11 +18,10 @@ export function isOfflineBrowseActive(): boolean {
*/
export function useOfflineBrowseActive(): boolean {
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const { status: connStatus } = useConnectionStatus();
// Shared status — all hook instances stay in sync after manual retry.
useConnectionStatus();
if (import.meta.env.DEV && devForceOffline) return true;
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
if (connStatus === 'disconnected') return true;
if (connStatus === 'connected') return false;
return !isActiveServerReachable();
}