diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc8e464..670c6ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 02ce0afe..bccc06ad 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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 (2–30 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)', ], }, { diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts index 9b72ce6d..b0193c2f 100644 --- a/src/hooks/useConnectionStatus.test.ts +++ b/src/hooks/useConnectionStatus.test.ts @@ -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'); + }); +}); diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index b3c3761f..94d79b91 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -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('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 effect’s 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); diff --git a/src/utils/network/activeServerReachability.test.ts b/src/utils/network/activeServerReachability.test.ts index 066977e3..4dda1cb1 100644 --- a/src/utils/network/activeServerReachability.test.ts +++ b/src/utils/network/activeServerReachability.test.ts @@ -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', () => { diff --git a/src/utils/network/activeServerReachability.ts b/src/utils/network/activeServerReachability.ts index f2cf3f57..44614713 100644 --- a/src/utils/network/activeServerReachability.ts +++ b/src/utils/network/activeServerReachability.ts @@ -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; diff --git a/src/utils/offline/offlineBrowseMode.test.ts b/src/utils/offline/offlineBrowseMode.test.ts index 5be3c33b..a25e081d 100644 --- a/src/utils/offline/offlineBrowseMode.test.ts +++ b/src/utils/offline/offlineBrowseMode.test.ts @@ -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', () => { diff --git a/src/utils/offline/offlineBrowseMode.ts b/src/utils/offline/offlineBrowseMode.ts index d6123c27..cddffd4d 100644 --- a/src/utils/offline/offlineBrowseMode.ts +++ b/src/utils/offline/offlineBrowseMode.ts @@ -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(); }