refactor(rundown): address review on the rundown scope

- extract getRundownCacheKey, the fallback to the bootstrap alias for an
  unresolved rundown id was spelled out in two readers and missing in the
  selection store, which could not resolve shift/ctrl ranges during startup
- an unresolved target is no longer reported as the loaded rundown, it was
  briefly enabling run mode and playback following before a rundown existed
- only the reader which bootstrapped drops the /current alias, a reader on
  another rundown was evicting a cache others still relied on
- the selection store returns a new Set on toggle and unselect, components
  selecting the set by reference never saw the change
- useSelectAndRevealEntry takes its rundown from the scope, callers were
  threading an id for no other reason

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
This commit is contained in:
Claude
2026-08-29 17:21:24 +00:00
parent 802521c706
commit bc8407c0e6
10 changed files with 64 additions and 39 deletions
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
@@ -1,7 +1,7 @@
import { Rundown } from 'ontime-types'; import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownQueryKey } from '../api/constants'; import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns'; import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore'; import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
@@ -19,7 +19,7 @@ const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren { export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */ /** rundown to operate on, null follows the loaded rundown */
rundownId: string | null; rundownId: MaybeString;
} }
/** /**
@@ -38,25 +38,26 @@ export function RundownScopeProvider({ children, rundownId }: RundownScopeProvid
// the store reads the rundown lazily, the ref keeps it pointing at the current target // the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId); const targetIdRef = useRef(targetId);
targetIdRef.current = targetId;
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null); const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) { if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() => selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(targetIdRef.current)), ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
); );
} }
const selectionStore = selectionStoreRef.current; const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target // a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => { useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents(); selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]); }, [selectionStore, targetId]);
const value = useMemo( const value = useMemo(
(): RundownScopeValue => ({ (): RundownScopeValue => ({
rundownId: targetId, rundownId: targetId,
isLoaded: targetId === loaded, // an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore, selectionStore,
}), }),
[targetId, loaded, selectionStore], [targetId, loaded, selectionStore],
@@ -1,9 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useRundownScope } from '../context/RundownScopeContext'; import { useRundownScope } from '../context/RundownScopeContext';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
@@ -32,7 +32,7 @@ export function useRundownById(rundownId: string | null | undefined) {
const isBootstrap = id === ''; const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: isBootstrap ? CURRENT_RUNDOWN_QUERY_KEY : getRundownQueryKey(id), queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })), queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -44,9 +44,12 @@ export function useRundownById(rundownId: string | null | undefined) {
queryClient.setQueryData(getRundownQueryKey(data.id), data); queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]); }, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache // Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => { useEffect(() => {
if (isBootstrap) return; if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]); }, [isBootstrap, queryClient]);
@@ -33,7 +33,7 @@ import {
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { getRundownCacheKey } from '../api/constants';
import { import {
ReorderEntry, ReorderEntry,
deleteEntries, deleteEntries,
@@ -77,7 +77,7 @@ export const useEntryActions = () => useEntryActionsForRundown(useRundownScope()
/** /**
* Gather utilities for actions on entries in an explicitly selected rundown. * Gather utilities for actions on entries in an explicitly selected rundown.
*/ */
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? ''); export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string) { function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -92,9 +92,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
inheritGroupColour, inheritGroupColour,
} = useEditorSettings(); } = useEditorSettings();
// an empty id means the loaded rundown has not resolved yet, the bootstrap alias holds the data
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
return scopedRundownId ? getRundownQueryKey(scopedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]); }, [scopedRundownId]);
/** /**
@@ -37,6 +37,23 @@ describe('createEventSelectionStore', () => {
expect(store.getState().anchoredIndex).toBe(2); expect(store.getState().anchoredIndex).toBe(2);
}); });
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => { it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined); const store = createEventSelectionStore(() => undefined);
@@ -1,11 +1,12 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: string | null; entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */ /** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: string | null; entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut'; entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, rundownId: string | null, mode?: 'copy' | 'cut') => void; setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
}; };
/** /**
@@ -15,6 +16,6 @@ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
entryCopyRundownId: null, entryCopyRundownId: null,
entryCopyMode: 'copy', entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, entryCopyRundownId: string | null, mode: 'copy' | 'cut' = 'copy') => setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }), set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
})); }));
@@ -63,7 +63,7 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
// if it doesnt exist, simply add to the list and set an anchor // if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) { if (!selectedEvents.has(id)) {
return set({ return set({
selectedEvents: selectedEvents.add(id), selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index, anchoredIndex: index,
cursor: id, cursor: id,
entryMode: 'event', entryMode: 'event',
@@ -72,15 +72,16 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
// if event is already selected, we remove it from selection // if event is already selected, we remove it from selection
// and set the anchor to the event after // and set the anchor to the event after
selectedEvents.delete(id); const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex( const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId), (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
); );
// if we didnt find anything after, set the anchor to the last event // if we didnt find anything after, set the anchor to the last event
return set({ return set({
selectedEvents, selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event', entryMode: 'event',
}); });
@@ -125,10 +126,11 @@ export function createEventSelectionStore(getRundown: () => Rundown | undefined)
}, },
unselect: (id: string) => { unselect: (id: string) => {
const { entryMode, selectedEvents } = get(); const { entryMode, selectedEvents } = get();
selectedEvents.delete(id); const remaining = new Set(selectedEvents);
remaining.delete(id);
set({ set({
selectedEvents, selectedEvents: remaining,
entryMode: selectedEvents.size === 0 ? null : entryMode, entryMode: remaining.size === 0 ? null : entryMode,
}); });
}, },
// Sets the scroll handler for programmatic scrolling to entries // Sets the scroll handler for programmatic scrolling to entries
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types'; import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups'; import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString; parent?: MaybeString;
}; };
export function useSelectAndRevealEntry(rundownId: string) { export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId); const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents); const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -207,10 +207,10 @@ export function searchByText(
* @param activeFilter - a field selected from the filter badges, if any * @param activeFilter - a field selected from the filter badges, if any
*/ */
export default function useFinder(searchValue: string, activeFilter: MaybeString) { export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown(); const { data } = useFlatRundown();
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */ /** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => { const filters = useMemo<FinderFilter[]>(() => {
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData} eventData={eventData}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId} resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/> />
); );
} }
@@ -56,21 +55,14 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[]; eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null; selectedEventId: string | null;
resolvedFollowEventId: string | null; resolvedFollowEventId: string | null;
rundownId: string;
} }
function TitleListContent({ function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
'use memo'; 'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null); const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null); const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); const selectAndRevealEntry = useSelectAndRevealEntry();
// Calculate current event info // Calculate current event info
const currentEventInfo = useMemo(() => { const currentEventInfo = useMemo(() => {