From 9e2dfaab16cfbb1631b4a9e2b25eb7329d483da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 18:53:44 +0000 Subject: [PATCH] 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. --- .../src/common/hooks-query/useCustomFields.ts | 3 ++- .../src/common/hooks-query/useProjectData.ts | 3 ++- apps/client/src/common/hooks-query/useRundown.ts | 5 +++-- .../client/src/common/hooks-query/useSettings.ts | 3 ++- .../src/common/hooks-query/useViewSettings.ts | 3 ++- .../common/utils/__tests__/queryUtils.test.ts | 16 ++++++++++++++++ apps/client/src/common/utils/queryUtils.ts | 13 +++++++++++++ apps/client/src/features/operator/Operator.tsx | 3 +-- 8 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/common/utils/__tests__/queryUtils.test.ts create mode 100644 apps/client/src/common/utils/queryUtils.ts 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 ; }