app: status and settings contexts say whether their first poll landed (#734)

Both contexts start as {} and nothing distinguished that from a
server that reported nothing, so for up to one poll interval after a
page load consumers rendered the negative case as fact: the Settings
form appeared with blank values and an "undefined/config/wrolpi.yaml"
path (the ready check `settings ? true : undefined` was always true),
the Downloads toggle was clickable and read "Downloading Disabled",
the upgrade panel said "up to date, vundefined", and the five hardware
toggles on the Controller page claimed "not supported on this server".

Invariants now enforced and tested:
* StatusContext.loaded is true once a poll returned a real status;
  the API being down does not count.  SettingsContext.loaded is true
  once settings were fetched, and `failed` when a fetch returned
  nothing before that; a failed refresh after a load keeps the last
  known values and `loaded`.
* The Settings page shows its loader until loaded and its error only
  when failed.  The upgrade panel shows a loader until status loaded.
  The Downloads toggle is disabled with a neutral label until then.
* A subsystem toggle is disabled without claiming "unsupported" while
  status is unknown (undefined); "unsupported" (null) is reserved for
  a loaded status that lacks the field.  A call in flight is unknown,
  not unsupported.

The context fixtures declare loaded so existing specs render as
before.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-09-26 17:25:46 -06:00 • committed by GitHub
parent 306386e801
commit eead78007d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 235 additions and 25 deletions

View file

@ -928,7 +928,10 @@ export function HelpHeader({
// why it cannot be used. `confirmStop` asks before switching off.
function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
// null = the status has loaded without this subsystem; undefined = the status has not loaded
// yet (or a call is in flight). Both disable the toggle; only the first claims unsupported.
const unsupported = on === null;
const unknown = on === undefined;
const handleChange = (checked) => {
if (!checked && confirmStop) {
@ -961,11 +964,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null,
</Confirm>}
<Toggle
label={label}
disabled={disabled === null ? unsupported : disabled}
disabled={disabled === null ? (unsupported || unknown) : disabled}
checked={on === true}
onChange={handleChange}
/>
{popup && <InfoPopup content={popup}/>}
{popup && <span data-testid='subsystem-popup'><InfoPopup content={popup}/></span>}
</div>;
}
@ -1065,9 +1068,10 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent =
export function DisableDownloadsToggle() {
const [pending, setPending] = React.useState(false);
const {status, fetchStatus} = React.useContext(StatusContext);
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
const {downloads} = status ? status : {downloads: null};
// Until the first status poll lands there is no downloads state to show, only a disabled toggle.
const downloads = loaded ? status?.downloads : null;
const wrolModeEnabled = useWROLMode();
const setDownloads = async (enable) => {
@ -1082,8 +1086,9 @@ export function DisableDownloadsToggle() {
}
const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false;
const label = downloads === null ? 'Downloading' : on === true ? 'Downloading Enabled' : 'Downloading Disabled';
return <Toggle
label={on === true ? 'Downloading Enabled' : 'Downloading Disabled'}
label={label}
disabled={wrolModeEnabled || pending || downloads === null}
checked={on === true}
onChange={setDownloads}

View file

@ -0,0 +1,69 @@
import React from 'react';
import {render, screen} from '../test-utils';
import {DisableDownloadsToggle, HotspotToggle} from './Common';
import {SettingsPage, UpgradeSegment} from './admin/Settings';
// The status and settings contexts start empty and are filled by the first poll. Until then a
// consumer must not claim a negative ("Downloading Disabled", "not supported", "up to date") or
// render a form over blank values; `loaded` on each context says whether the first poll landed.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getConfigs: jest.fn(() => new Promise(() => {
})),
}));
const unloadedStatus = {status: {}, loaded: false};
describe('DisableDownloadsToggle before status has loaded', () => {
test('is disabled and does not claim downloading is disabled', () => {
render(<DisableDownloadsToggle/>, {status: unloadedStatus});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.queryByText('Downloading Disabled')).not.toBeInTheDocument();
});
test('once loaded, reads the downloads state', () => {
render(<DisableDownloadsToggle/>, {status: {status: {downloads: {disabled: true, stopped: false}}, loaded: true}});
expect(screen.getByText('Downloading Disabled')).toBeInTheDocument();
expect(screen.getByTestId('toggle')).toBeEnabled();
});
});
describe('HotspotToggle before status has loaded', () => {
test('is disabled without claiming the hotspot is unsupported', () => {
render(<HotspotToggle/>, {status: unloadedStatus});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.queryByTestId('subsystem-popup')).not.toBeInTheDocument();
});
test('once loaded without a hotspot field, is unsupported', () => {
render(<HotspotToggle/>, {status: {status: {}, loaded: true}});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.getByTestId('subsystem-popup')).toBeInTheDocument();
});
});
describe('UpgradeSegment before status has loaded', () => {
test('does not claim the WROLPi is up to date', () => {
render(<UpgradeSegment/>, {status: unloadedStatus});
expect(screen.queryByText(/up to date/)).not.toBeInTheDocument();
});
test('once loaded with no update, says so', () => {
render(<UpgradeSegment/>, {status: {status: {update_available: false, version: '1.0'}, loaded: true}});
expect(screen.getByText(/up to date/)).toBeInTheDocument();
});
});
describe('SettingsPage before settings have loaded', () => {
test('shows a loader, not a form over blank values', () => {
const {container} = render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: false}});
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(/Any changes will be written/)).not.toBeInTheDocument();
});
test('shows an error when the settings could not be fetched', () => {
render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: true}});
expect(screen.getByText('Unable to fetch settings')).toBeInTheDocument();
});
});

View file

@ -101,8 +101,8 @@ export function ShutdownButton() {
</APIButton>
}
function UpgradeSegment() {
const {status, fetchStatus} = React.useContext(StatusContext);
export function UpgradeSegment() {
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
const dockerized = useDockerized();
const [upgrading, setUpgrading] = React.useState(false);
const [checking, setChecking] = React.useState(false);
@ -144,6 +144,14 @@ function UpgradeSegment() {
</Panel>;
}
// Nothing is known until the first status poll lands; "up to date" is a claim about the server.
if (!loaded) {
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<Loading/>
</Panel>;
}
// No update available
if (!status?.update_available) {
return <Panel id='upgrade'>
@ -325,7 +333,9 @@ export function SettingsPage() {
const [isDevMode, setIsDevMode] = React.useState(false);
// Get current settings from the API.
const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext);
const {
settings, saveSettings, fetchSettings, loaded: settingsLoaded, failed: settingsFailed,
} = React.useContext(SettingsContext);
const {status} = React.useContext(StatusContext);
// Used to track changes to the settings form between saves/loads.
const [state, setState] = React.useState({});
@ -463,7 +473,8 @@ export function SettingsPage() {
React.useEffect(() => {
console.debug('settings changed, replacing state...');
setReady(settings ? true : undefined);
// true = show the form, false = still loading, undefined = could not fetch.
setReady(settingsLoaded ? true : settingsFailed ? undefined : false);
setState({
// Only these settings can be changed on the SettingsPage.
archive_destination: settings.archive_destination,
@ -490,7 +501,7 @@ export function SettingsPage() {
playlists_destination: settings.playlists_destination,
save_ffprobe_json: settings.save_ffprobe_json,
});
}, [JSON.stringify(settings)]);
}, [JSON.stringify(settings), settingsLoaded, settingsFailed]);
const handleInputChange = async (name, value) => {
setState({...state, [name]: value});

View file

@ -46,6 +46,8 @@ export const ThemeContext = React.createContext({
export const StatusContext = React.createContext({
status: {},
fetchStatus: null,
// True once the first status poll has landed.
loaded: false,
});
export const SettingsContext = React.createContext({
@ -53,6 +55,9 @@ export const SettingsContext = React.createContext({
fetchSettings: null,
saveSettings: null,
pending: false,
// True once settings have been fetched; `failed` when a fetch returned nothing before that.
loaded: false,
failed: false,
});
// `useQuery`

View file

@ -1154,7 +1154,8 @@ export const useDownloads = () => {
// Every hardware toggle works the same way: one field of the shared status object
// says whether the subsystem is on, and starting/stopping it is a Controller call.
// `on` is null when the status is unknown, which the UI renders as unsupported.
// `on` is undefined until the status has loaded (unknown, the toggle is disabled), null once it
// has loaded without the field (unsupported on this server), and otherwise a boolean.
const useSubsystemToggle = ({
statusField,
onValue = 'on',
@ -1165,23 +1166,26 @@ const useSubsystemToggle = ({
errorDescription,
refetchStatus = false,
}) => {
const [on, setOn] = useState(null);
const {status, fetchStatus} = useContext(StatusContext);
const [on, setOn] = useState(undefined);
const {status, fetchStatus, loaded} = useContext(StatusContext);
const value = status?.[statusField];
useEffect(() => {
if (value === onValue) {
if (!loaded) {
setOn(undefined);
} else if (value === onValue) {
setOn(true);
} else if (offValues.includes(value)) {
setOn(false);
} else {
setOn(null);
}
}, [value]);
}, [value, loaded]);
const setSubsystem = async (enable) => {
const previous = on;
setOn(null);
// Unknown while the call is in flight, not unsupported.
setOn(undefined);
try {
if (enable) {
await start();
@ -1331,8 +1335,16 @@ export const useSearchDirectories = (value) => {
}
export const useSettings = () => {
// `settings` is {} until the first fetch lands, and a consumer cannot tell that from real
// settings by looking at it. `loaded` is true once a fetch returned settings; `failed` is
// true when a fetch returned nothing before anything loaded. A failed refresh after a load
// keeps the last known good values and `loaded`.
const [settings, setSettings] = useState({});
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const [pending, setPending] = useState(false);
// Read by fetchSettings after an await, when the closed-over `loaded` may be stale.
const loadedRef = useRef(false);
const fetchSettings = async () => {
if (window.apiDown) {
@ -1343,9 +1355,17 @@ export const useSettings = () => {
const newSettings = await getSettings();
if (newSettings) {
setSettings(newSettings);
loadedRef.current = true;
setLoaded(true);
setFailed(false);
} else if (!loadedRef.current) {
setFailed(true);
}
} catch (e) {
// Don't clear settings on error — keep the last known good values.
if (!loadedRef.current) {
setFailed(true);
}
} finally {
setPending(false);
}
@ -1355,15 +1375,15 @@ export const useSettings = () => {
fetchSettings();
}, []);
return {settings, pending, fetchSettings, saveSettings};
return {settings, loaded, failed, pending, fetchSettings, saveSettings};
}
export const useSettingsInterval = () => {
const {settings, pending, fetchSettings, saveSettings} = useSettings();
const {settings, loaded, failed, pending, fetchSettings, saveSettings} = useSettings();
useRecurringTimeout(fetchSettings, 1000 * 10);
return {settings, pending, fetchSettings, saveSettings};
return {settings, loaded, failed, pending, fetchSettings, saveSettings};
}
export const useMediaDirectory = () => {
@ -1373,7 +1393,11 @@ export const useMediaDirectory = () => {
}
export const useStatus = () => {
// `status` is {} until the first poll lands, and a consumer cannot tell that from a server
// that reported nothing. `loaded` is true once a poll returned a real status; the API being
// down does not count.
const [status, setStatus] = useState({});
const [loaded, setLoaded] = useState(false);
const fetchStatus = async () => {
try {
@ -1398,6 +1422,9 @@ export const useStatus = () => {
// Merge both responses - controller stats override app status for system info
setStatus({...appStatus, ...controllerStats});
if (!window.apiDown) {
setLoaded(true);
}
} catch (e) {
if (e instanceof ApiDownError) {
// API is down, do not log this error.
@ -1417,15 +1444,15 @@ export const useStatus = () => {
window.apiDown = false;
}, []);
return {status, fetchStatus}
return {status, loaded, fetchStatus}
}
export const useStatusInterval = () => {
const {status, fetchStatus} = useStatus();
const {status, loaded, fetchStatus} = useStatus();
useRecurringTimeout(fetchStatus, 1000 * 3);
return {status, fetchStatus};
return {status, loaded, fetchStatus};
}
export const StatusProvider = (props) => {

View file

@ -2,9 +2,13 @@ import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {
usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useStatistics, useVideoExtras,
usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSettings, useStatistics, useStatus,
useVideoExtras,
} from './customHooks';
import {getStatistics, getVideoComments, getVideoDescription, searchChannels} from '../api';
import {
ApiDownError, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription, searchChannels,
} from '../api';
import {getControllerStats} from '../api/controller';
import {QueryContext, StatusContext} from '../contexts/contexts';
import {Paginator} from '../components/Common';
@ -42,6 +46,13 @@ jest.mock('../api', () => ({
getVideoDescription: jest.fn(),
searchChannels: jest.fn(),
getStatistics: jest.fn(),
getStatus: jest.fn(),
getSettings: jest.fn(),
}));
jest.mock('../api/controller', () => ({
...jest.requireActual('../api/controller'),
getControllerStats: jest.fn(),
}));
// Mock Media so Paginator renders both mobile + tablet variants synchronously.
@ -580,3 +591,81 @@ describe('useStatistics', () => {
}
});
});
describe('useStatus loaded', () => {
// `loaded` is false until the first poll returns a real status, so consumers can tell "not
// polled yet" from "the server reported nothing".
beforeEach(() => {
getStatus.mockReset();
getControllerStats.mockReset();
getControllerStats.mockResolvedValue({});
});
test('is false before any poll', () => {
const {result} = renderHook(() => useStatus());
expect(result.current.loaded).toBe(false);
expect(result.current.status).toEqual({});
});
test('is true once a poll returned a status', async () => {
getStatus.mockResolvedValue({version: '1.0'});
const {result} = renderHook(() => useStatus());
await act(async () => {
await result.current.fetchStatus();
});
expect(result.current.loaded).toBe(true);
expect(result.current.status.version).toBe('1.0');
});
test('stays false while the API is down', async () => {
getStatus.mockRejectedValue(new ApiDownError('down'));
const {result} = renderHook(() => useStatus());
await act(async () => {
await result.current.fetchStatus();
});
expect(result.current.loaded).toBe(false);
});
});
describe('useSettings loaded', () => {
beforeEach(() => {
getSettings.mockReset();
window.apiDown = false;
});
test('is false before the fetch returns, then true', async () => {
let resolve;
getSettings.mockReturnValue(new Promise(res => resolve = res));
const {result} = renderHook(() => useSettings());
expect(result.current.loaded).toBe(false);
expect(result.current.failed).toBe(false);
await act(async () => {
resolve({media_directory: '/media/wrolpi'});
});
expect(result.current.loaded).toBe(true);
expect(result.current.settings.media_directory).toBe('/media/wrolpi');
});
test('a fetch that returns nothing before anything loaded marks failed', async () => {
getSettings.mockResolvedValue(undefined);
const {result} = renderHook(() => useSettings());
await act(async () => {
});
expect(result.current.loaded).toBe(false);
expect(result.current.failed).toBe(true);
});
test('a failed refresh after a successful load keeps loaded and the last settings', async () => {
getSettings.mockResolvedValueOnce({media_directory: '/media/wrolpi'});
const {result} = renderHook(() => useSettings());
await act(async () => {
});
getSettings.mockRejectedValue(new Error('boom'));
await act(async () => {
await result.current.fetchSettings();
});
expect(result.current.loaded).toBe(true);
expect(result.current.failed).toBe(false);
expect(result.current.settings.media_directory).toBe('/media/wrolpi');
});
});

View file

@ -23,7 +23,7 @@ const controllerApi = require('../api/controller');
const fetchStatus = jest.fn();
const makeWrapper = (status) => ({children}) => (
<StatusContext.Provider value={{status, fetchStatus}}>{children}</StatusContext.Provider>
<StatusContext.Provider value={{status, fetchStatus, loaded: true}}>{children}</StatusContext.Provider>
);
beforeEach(() => {

View file

@ -188,9 +188,11 @@ export const themeContextFixture = (overrides = {}) => ({
...overrides,
});
// `loaded` says the first poll has landed; the fixtures carry real values, so they are loaded.
export const statusContextFixture = (overrides = {}) => ({
status: statusFixture(),
fetchStatus: () => Promise.resolve(),
loaded: true,
...overrides,
});
@ -199,6 +201,8 @@ export const settingsContextFixture = (overrides = {}) => ({
fetchSettings: () => Promise.resolve(),
saveSettings: () => Promise.resolve(),
pending: false,
loaded: true,
failed: false,
...overrides,
});