diff --git a/apps/client/src/common/hooks-query/useCustomFields.ts b/apps/client/src/common/hooks-query/useCustomFields.ts index 15dde9194..d0a5fe028 100644 --- a/apps/client/src/common/hooks-query/useCustomFields.ts +++ b/apps/client/src/common/hooks-query/useCustomFields.ts @@ -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 }; } diff --git a/apps/client/src/common/hooks-query/useProjectData.ts b/apps/client/src/common/hooks-query/useProjectData.ts index f806f1a17..03a022e6f 100644 --- a/apps/client/src/common/hooks-query/useProjectData.ts +++ b/apps/client/src/common/hooks-query/useProjectData.ts @@ -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() { diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 4912f688b..4a5cd599b 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -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 }; } diff --git a/apps/client/src/common/hooks-query/useSettings.ts b/apps/client/src/common/hooks-query/useSettings.ts index e14676424..d6103c50d 100644 --- a/apps/client/src/common/hooks-query/useSettings.ts +++ b/apps/client/src/common/hooks-query/useSettings.ts @@ -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 }; } diff --git a/apps/client/src/common/hooks-query/useViewSettings.ts b/apps/client/src/common/hooks-query/useViewSettings.ts index dc3a3b621..05532674e 100644 --- a/apps/client/src/common/hooks-query/useViewSettings.ts +++ b/apps/client/src/common/hooks-query/useViewSettings.ts @@ -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 }; } diff --git a/apps/client/src/common/utils/__tests__/queryUtils.test.ts b/apps/client/src/common/utils/__tests__/queryUtils.test.ts new file mode 100644 index 000000000..65018ec8c --- /dev/null +++ b/apps/client/src/common/utils/__tests__/queryUtils.test.ts @@ -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'); +}); diff --git a/apps/client/src/common/utils/queryUtils.ts b/apps/client/src/common/utils/queryUtils.ts new file mode 100644 index 000000000..a4ee7e7fb --- /dev/null +++ b/apps/client/src/common/utils/queryUtils.ts @@ -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; +} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index e389cc554..f1b6ffc41 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -36,8 +36,7 @@ export default function OperatorLoader() { return ; } - // 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 ; }