fix(views): move stale-data-on-error fix to the shared query hooks

The previous Operator-only fix (checking rundown.revision) papered over a
bug shared by every view loader: react-query sets status to 'error' on any
failed fetch, including a background refetch, even when a prior successful
fetch's data is still cached. All view loaders treated that as "no data"
and blanked the whole view.

Add deriveQueryStatus(), a small helper that only reports 'error' when a
query has never received data, and apply it in the five base data hooks
(useRundown, useRundownById, useCustomFields, useSettings, useProjectData,
useViewSettings). This fixes the class of bug for every consumer (Operator,
Timer, Backstage, Studio, Countdown, TimelinePage, ProjectInfo, RundownList,
CuesheetTable) at the source, so the Operator-specific revision check can
be reverted back to the plain status check.
This commit is contained in:
Claude
2026-08-01 18:53:44 +00:00
parent 65e1d3cfe9
commit 9e2dfaab16
8 changed files with 41 additions and 8 deletions
@@ -4,6 +4,7 @@ import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
import { deriveQueryStatus } from '../utils/queryUtils';
const placeholder: CustomFields = {};
@@ -15,5 +16,5 @@ export default function useCustomFields() {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
return { data: data ?? placeholder, status: deriveQueryStatus(status, data), isFetching, isError, refetch };
}
@@ -4,6 +4,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
import { deriveQueryStatus } from '../utils/queryUtils';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
@@ -13,7 +14,7 @@ export default function useProjectData() {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
return { data: data ?? projectDataPlaceholder, status: deriveQueryStatus(status, data), isFetching, isError, refetch };
}
export function useUpdateProjectData() {
@@ -6,6 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { deriveQueryStatus } from '../utils/queryUtils';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -50,7 +51,7 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status: deriveQueryStatus(status, data), isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
@@ -135,5 +136,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status: deriveQueryStatus(status, data), isError, refetch, isFetching };
}
@@ -4,6 +4,7 @@ import { unobfuscate } from 'ontime-utils';
import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
import { deriveQueryStatus } from '../utils/queryUtils';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
@@ -22,5 +23,5 @@ export default function useSettings() {
},
});
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
return { data: data ?? ontimePlaceholderSettings, status: deriveQueryStatus(status, data), isFetching, isError, refetch };
}
@@ -5,6 +5,7 @@ import { getViewSettings, postViewSettings } from '../../common/api/viewSettings
import { ontimeQueryClient } from '../../common/queryClient';
import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
import { deriveQueryStatus } from '../utils/queryUtils';
export default function useViewSettings() {
const { data, status } = useQuery({
@@ -24,5 +25,5 @@ export default function useViewSettings() {
},
});
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
return { data: data ?? viewsSettingsPlaceholder, status: deriveQueryStatus(status, data), mutateAsync };
}
@@ -0,0 +1,16 @@
import { deriveQueryStatus } from '../queryUtils';
test('keeps pending and success statuses unchanged', () => {
expect(deriveQueryStatus('pending', undefined)).toBe('pending');
expect(deriveQueryStatus('success', { some: 'data' })).toBe('success');
});
test('keeps error status when there is no data', () => {
expect(deriveQueryStatus('error', undefined)).toBe('error');
});
test('downgrades error to success when data is still available', () => {
expect(deriveQueryStatus('error', { some: 'data' })).toBe('success');
expect(deriveQueryStatus('error', [])).toBe('success');
expect(deriveQueryStatus('error', 0)).toBe('success');
});
@@ -0,0 +1,13 @@
import type { QueryStatus } from '@tanstack/react-query';
/**
* A background refetch failure still leaves the last successfully fetched data in place.
* In that case we want callers to keep treating the query as usable rather than erroring out,
* so we only report 'error' when we have never received data for this query.
*/
export function deriveQueryStatus(status: QueryStatus, data: unknown): QueryStatus {
if (status === 'error' && data !== undefined) {
return 'success';
}
return status;
}
@@ -36,8 +36,7 @@ export default function OperatorLoader() {
return <Loader />;
}
// only show the error state if we have never received data to fall back on
if (status === 'error' && data.rundown.revision === -1) {
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}