app: Flasher saved configs and Statistics distinguish pending from empty

The Flasher's saved configurations started as [] with no loading
flag, and the Saved Firmwares tab is the default tab, so every load
painted "No saved configurations yet." until the fetch returned.  The
api helper returned [] on a non-OK response too, so a failed fetch
read as an empty list.

useStatistics used useState() with no initial value, so the
Statistics page's first render saw undefined and flashed "Failed to
fetch statistics" for one frame before the effect set {} and the
spinner appeared.

Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] / object = loaded.  The
  api helper returns undefined (and toasts) on a non-OK response.
* The Saved Firmwares tab shows a placeholder while pending, an error
  on failure, and "No saved configurations yet." only once the server
  confirmed there are none.  Only the newest configs request may write
  state, since save and delete refetch.
* The Statistics page shows its loader while pending and its error
  only for a failed fetch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-09-26 17:01:47 -06:00
parent 6e251a009a
commit a33083cc27
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 158 additions and 14 deletions

View file

@ -1361,7 +1361,9 @@ export async function getFlasherConfigs() {
const data = await response.json();
return data['configurations'] || [];
}
return [];
// Undefined, not [], so the page can tell a failed fetch from an empty list.
const message = await getErrorMessage(response, 'Could not get saved firmware configurations');
toast({type: 'error', title: 'Flasher Error', description: message, time: 5000});
}
export async function saveFlasherConfig(name, files, eraseAll) {

View file

@ -14,7 +14,8 @@ import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
import {BookmarksPage} from "./Bookmarks";
function StatisticsPage() {
// `statistics` follows useStatistics: null = pending, undefined = fetch failed.
export function StatisticsPage() {
useTitle('Statistics');
const {statistics} = useStatistics();
@ -26,7 +27,7 @@ function StatisticsPage() {
</>
}
if (statistics['global_statistics']) {
if (statistics && statistics['global_statistics']) {
let {global_statistics, file_statistics} = statistics;
const {
archive_count,

View file

@ -3,7 +3,7 @@ import {Route, Routes} from "react-router";
import {ESPLoader, Transport} from "esptool-js";
import {useDropzone} from "react-dropzone";
import _ from "lodash";
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
import {encodeMediaPath, ErrorMessage, humanFileSize, PageContainer, useTitle} from "./Common";
import {
Button,
Checkbox,
@ -17,6 +17,7 @@ import {
Message,
Modal,
Panel,
Placeholder,
Progress,
Select,
Stack,
@ -297,8 +298,12 @@ export function FlasherPage() {
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
const [activeTab, setActiveTab] = useState(TAB_SAVED);
// Saved firmware configurations (flasher.yaml).
const [savedConfigs, setSavedConfigs] = useState([]);
// Saved firmware configurations (flasher.yaml). null = pending, undefined = fetch failed,
// [] = none saved; the "No saved configurations yet." message is only for the last.
const [savedConfigs, setSavedConfigs] = useState(null);
// Only the newest configs request may write state: a save or delete refetches while the mount
// request may still be in flight.
const savedConfigsGen = useRef(0);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveName, setSaveName] = useState('');
@ -424,7 +429,19 @@ export function FlasherPage() {
// Saved firmware configurations.
const fetchSavedConfigs = React.useCallback(async () => {
setSavedConfigs(await getFlasherConfigs());
const gen = ++savedConfigsGen.current;
try {
const configs = await getFlasherConfigs();
if (gen === savedConfigsGen.current) {
// The api helper returns undefined on a non-OK response.
setSavedConfigs(configs ?? undefined);
}
} catch (e) {
console.error(e);
if (gen === savedConfigsGen.current) {
setSavedConfigs(undefined);
}
}
}, []);
useEffect(() => {
@ -812,7 +829,9 @@ export function FlasherPage() {
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
{savedConfigs === null && <Placeholder lines={2}/>}
{savedConfigs === undefined && <ErrorMessage>Could not fetch saved configurations.</ErrorMessage>}
{Array.isArray(savedConfigs) && (savedConfigs.length > 0
? <Panel style={{padding: 0}}>
{savedConfigs.map((configuration, index) => <div
key={configuration.name}
@ -846,7 +865,7 @@ export function FlasherPage() {
onClick={() => handleDeleteConfig(configuration.name)}/>
</div>)}
</Panel>
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>)}
</>;
return <div {...getRootProps()}>

View file

@ -265,6 +265,31 @@ describe('FlasherPage', () => {
}
});
// The Saved Firmwares tab is the default tab. "No saved configurations yet." may only appear once the
// server confirmed there are none; a pending or failed fetch must not read as an empty list.
it('shows a placeholder, not "no saved configurations", while the configs load', () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
getFlasherConfigs.mockReturnValue(new Promise(() => {
}));
const {container} = render(<FlasherPage/>);
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
});
it('shows an error, not "no saved configurations", when the configs fetch failed', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
getFlasherConfigs.mockResolvedValue(undefined);
render(<FlasherPage/>);
expect((await screen.findAllByText(/Could not fetch saved configurations/)).length).toBeGreaterThan(0);
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
});
it('shows "no saved configurations" once the server confirmed there are none', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
render(<FlasherPage/>);
expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0);
});
// PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice.
it('warns when Web Serial is unavailable', () => {
delete global.navigator.serial;

View file

@ -0,0 +1,49 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {StatisticsPage} from './Apps';
import {getStatistics} from '../api';
// What the Statistics page shows while the numbers are still loading, when the fetch failed, and
// once they arrive. "Failed to fetch statistics" may only appear for a failed fetch.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getStatistics: jest.fn(),
}));
const FAILED = /Failed to fetch statistics/;
const STATS = {
global_statistics: {db_size: 1024},
file_statistics: {
archive_count: 1, audio_count: 2, ebook_count: 3, image_count: 4, pdf_count: 5, total_count: 21,
video_count: 6, zip_count: 0, total_size: 4096, tagged_files: 7, tagged_zims: 0, tags_count: 2,
},
};
beforeEach(() => getStatistics.mockReset());
test('pending: shows a loader, not the error', () => {
getStatistics.mockReturnValue(new Promise(() => {
}));
const {container} = render(<StatisticsPage/>);
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
});
test('failed: shows the error', async () => {
// The api helper toasts and returns undefined on a non-OK response.
getStatistics.mockResolvedValue(undefined);
render(<StatisticsPage/>);
await act(async () => {
});
expect(screen.getByText(FAILED)).toBeInTheDocument();
});
test('loaded: shows the numbers', async () => {
getStatistics.mockResolvedValue(STATS);
render(<StatisticsPage/>);
await act(async () => {
});
expect(screen.getByText('All Files')).toBeInTheDocument();
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
});

View file

@ -1697,12 +1697,15 @@ export const collectLocations = (inventories) => {
};
export const useStatistics = () => {
const [statistics, setStatistics] = useState();
// null = pending, undefined = fetch failed (the api helper returns undefined on a non-OK
// response), an object = loaded. Never undefined on the first render: the page shows its
// error for undefined.
const [statistics, setStatistics] = useState(null);
const fetchFileStatistics = async () => {
try {
const s = await getStatistics();
setStatistics(s);
setStatistics(s ?? undefined);
} catch (e) {
console.error(e);
setStatistics(undefined);
@ -1710,7 +1713,6 @@ export const useStatistics = () => {
}
useEffect(() => {
setStatistics({});
fetchFileStatistics();
}, []);

View file

@ -1,8 +1,10 @@
import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useVideoExtras} from './customHooks';
import {getVideoComments, getVideoDescription, searchChannels} from '../api';
import {
usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useStatistics, useVideoExtras,
} from './customHooks';
import {getStatistics, getVideoComments, getVideoDescription, searchChannels} from '../api';
import {QueryContext, StatusContext} from '../contexts/contexts';
import {Paginator} from '../components/Common';
@ -39,6 +41,7 @@ jest.mock('../api', () => ({
getVideoComments: jest.fn(),
getVideoDescription: jest.fn(),
searchChannels: jest.fn(),
getStatistics: jest.fn(),
}));
// Mock Media so Paginator renders both mobile + tablet variants synchronously.
@ -534,3 +537,46 @@ describe('useSearchChannels', () => {
expect(result.current.loading).toBe(false);
});
});
describe('useStatistics', () => {
// null = pending, undefined = fetch failed, an object = loaded. The Statistics page shows its
// error only for undefined, so the first render must not be undefined.
beforeEach(() => getStatistics.mockReset());
test('is pending (null) on the first render', () => {
getStatistics.mockReturnValue(new Promise(() => {
}));
const {result} = renderHook(() => useStatistics());
expect(result.current.statistics).toBeNull();
});
test('resolves to the statistics', async () => {
getStatistics.mockResolvedValue({global_statistics: {}, file_statistics: {}});
const {result} = renderHook(() => useStatistics());
await act(async () => {
});
expect(result.current.statistics).toEqual({global_statistics: {}, file_statistics: {}});
});
test('a non-OK response yields undefined', async () => {
getStatistics.mockResolvedValue(undefined);
const {result} = renderHook(() => useStatistics());
await act(async () => {
});
expect(result.current.statistics).toBeUndefined();
});
test('a thrown request yields undefined', async () => {
getStatistics.mockRejectedValue(new Error('boom'));
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
const {result} = renderHook(() => useStatistics());
await act(async () => {
});
expect(result.current.statistics).toBeUndefined();
} finally {
spy.mockRestore();
}
});
});