import { ReactNode, useState, useCallback, useRef, createContext, useContext } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { Bundle } from 'fhir/r4'; import { useFhirServiceContext } from './FhirService.tsx'; import { sectionReadFailure, type SectionReadFailure } from '../utils/sectionReadRefusal'; export interface PaginatorConfig { pageSize?: number; searchParamName?: string; debounceMs?: number; } export interface PaginatorState { items: T[]; /** * WHY THERE ARE NO ITEMS, WHEN THE REASON IS NOT "THERE ARE NONE". * * MEASURED as a staff member holding Human Resources and Medical Assistant: `GET /R4/Patient` * answered **403** twice and the Patients page said **"No items found."** - a practice with no * patients and a practice whose patients this person may not read are the same picture, and the * second is the ordinary case, because a workspace is not a permission. * * Both loads used to `console.error` and return, so the refusal reached nothing that could render * it. It is `null` on a successful load and on the first render, so absence still means "nothing * has gone wrong" rather than "we have not checked". */ readFailure: SectionReadFailure | null; bundle: Bundle | null; loading: boolean; searchQuery: string; displaySearchQuery: string; pages: string[]; canGoNext: boolean; canGoPrev: boolean; } export interface PaginatorActions { search: (query: string) => void; paginate: (direction: 'prev' | 'next') => Promise; refresh: () => Promise; reset: () => void; addItem: (item: any) => void; updateItem: (item: any) => void; deleteItem: (id: string) => void; initializeFromUrl: ( fetchFunction: (pageSize: number, pageParam?: string, query?: string) => Promise>, config?: PaginatorConfig, context?: string ) => void; } const globalPaginatorStates = new Map | null; items: any[]; pages: string[]; searchQuery: string; displaySearchQuery: string; }>(); // Contexts explicitly marked stale (e.g. after creating a new item on a different route). The next // initializeFromUrl for such a context bypasses the same-context short-circuit and refetches. const invalidatedPaginatorContexts = new Set(); // Force the next Paginator init for `context` to refetch instead of restoring its cached page. // Callable from anywhere (module scope), unlike the per-instance reset()/refresh() actions. export function invalidatePaginatorContext(context: string): void { invalidatedPaginatorContexts.add(context); globalPaginatorStates.delete(context); } export const usePaginatorService = () => { const navigate = useNavigate(); const location = useLocation(); const { unpackBundle } = useFhirServiceContext(); const [bundle, setBundle] = useState | null>(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [readFailure, setReadFailure] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [displaySearchQuery, setDisplaySearchQuery] = useState(''); const [pages, setPages] = useState(['']); const [searchTimeout, setSearchTimeout] = useState(null); const [currentConfig, setCurrentConfig] = useState({ pageSize: 10, searchParamName: 'search', debounceMs: 500 }); const [currentFetchFunction, setCurrentFetchFunction] = useState<((pageSize: number, pageParam?: string, query?: string) => Promise>) | null>(null); const [currentStateKey, setCurrentStateKey] = useState(''); const lastInitializedStateKey = useRef(''); // Only the newest request may fill the list: a slow answer for "ad" never lands under "ada". const latestLoad = useRef(0); const saveToGlobalState = ( bundle: Bundle | null, items: any[], pages: string[], searchQuery: string, displaySearchQuery: string ) => { globalPaginatorStates.set(currentStateKey, { bundle, items, pages, searchQuery, displaySearchQuery }); }; const getNextPageParam = (bundle: Bundle | null): string => { if (!bundle?.link) return ''; const nextLink = bundle.link.find(link => link.relation === 'next'); if (nextLink?.url) { const pageMatch = nextLink.url.match(/page=([^&]*)/); return pageMatch ? pageMatch[1] : ''; } return ''; }; const updateQueryParams = (query: string = '') => { const queryParams = new URLSearchParams(); if (query) { queryParams.set(currentConfig.searchParamName!, query); } const queryString = queryParams.toString(); const newUrl = `${location.pathname}${queryString ? `?${queryString}` : ''}`; navigate(newUrl, { replace: true }); }; const loadPage = useCallback(async (pageParam?: string, query: string = searchQuery, updatedPages?: string[]) => { if (!currentFetchFunction) return null; const ticket = ++latestLoad.current; setLoading(true); try { const fetchedBundle = await currentFetchFunction(currentConfig.pageSize!, pageParam, query); if (ticket !== latestLoad.current) return null; const itemList = unpackBundle(fetchedBundle); setBundle(fetchedBundle); setItems(itemList); setReadFailure(null); updateQueryParams(query); const pagesToSave = updatedPages || pages; saveToGlobalState(fetchedBundle, itemList, pagesToSave, query, query); return fetchedBundle; } catch (error) { if (ticket !== latestLoad.current) return null; console.error('Failed to load page:', error); // A BARE CATCH WAS THE DEFECT. Discarding the error left a refusal and a dropped // connection in the identical state, and the empty branch below then spoke for both. // `sectionReadFailure` is the same 401/403 test the patient's own sections use, so the // two cannot disagree about what a refusal is. setReadFailure(sectionReadFailure(error)); return null; } finally { if (ticket === latestLoad.current) setLoading(false); } }, [currentFetchFunction, currentConfig.pageSize, searchQuery, unpackBundle, pages, currentStateKey]); const search = (value: string) => { setDisplaySearchQuery(value); if (searchTimeout) { clearTimeout(searchTimeout); } const timeout = setTimeout(() => { setSearchQuery(value.toLowerCase()); setPages(['']); loadPage(undefined, value.toLowerCase()); }, currentConfig.debounceMs); setSearchTimeout(timeout as unknown as NodeJS.Timeout); }; const paginate = useCallback(async (direction: 'prev' | 'next') => { if (loading) return; if (direction === 'prev') { if (pages.length <= 1) return; const newPages = [...pages]; newPages.pop(); setPages(newPages); const prevPage = newPages[newPages.length - 1]; await loadPage(prevPage === '' ? undefined : prevPage, searchQuery, newPages); } else { const nextPageParam = getNextPageParam(bundle); if (!nextPageParam) return; let newPages = [...pages]; if (!pages.includes(nextPageParam)) { newPages = [...pages, nextPageParam]; setPages(newPages); } await loadPage(nextPageParam, searchQuery, newPages); } }, [loading, pages, bundle, loadPage, searchQuery]); const refresh = useCallback(async () => { const queryParams = new URLSearchParams(location.search); const searchParam = queryParams.get(currentConfig.searchParamName!) || ''; setPages(['']); globalPaginatorStates.delete(currentStateKey); await loadPage(undefined, searchParam); }, [location.search, currentConfig.searchParamName, loadPage, currentStateKey]); const reset = () => { setBundle(null); setItems([]); setSearchQuery(''); setDisplaySearchQuery(''); setPages(['']); globalPaginatorStates.delete(currentStateKey); lastInitializedStateKey.current = ''; }; const addItem = (item: any) => { setItems(prevItems => { const updatedItems = [item, ...prevItems]; const currentState = globalPaginatorStates.get(currentStateKey); if (currentState) { saveToGlobalState( currentState.bundle, updatedItems, currentState.pages, currentState.searchQuery, currentState.displaySearchQuery ); } return updatedItems; }); }; const updateItem = (item: any) => { setItems(prevItems => { const updatedItems = prevItems.map(existingItem => existingItem.id === item.id ? item : existingItem); const currentState = globalPaginatorStates.get(currentStateKey); if (currentState) { saveToGlobalState( currentState.bundle, updatedItems, currentState.pages, currentState.searchQuery, currentState.displaySearchQuery ); } return updatedItems; }); }; const deleteItem = (id: string) => { setItems(prevItems => { const updatedItems = prevItems.filter(item => item.id !== id); const currentState = globalPaginatorStates.get(currentStateKey); if (currentState) { saveToGlobalState( currentState.bundle, updatedItems, currentState.pages, currentState.searchQuery, currentState.displaySearchQuery ); } return updatedItems; }); }; const initializeFromUrl = useCallback(( fetchFunction: (pageSize: number, pageParam?: string, query?: string) => Promise>, config: PaginatorConfig = {}, context?: string ) => { const finalConfig = { pageSize: 10, searchParamName: 'search', debounceMs: 500, ...config }; const stateKey = context || location.pathname; const isDifferentContext = lastInitializedStateKey.current !== stateKey; const isInvalidated = invalidatedPaginatorContexts.has(stateKey); if (isInvalidated) { invalidatedPaginatorContexts.delete(stateKey); } if (!isDifferentContext && lastInitializedStateKey.current !== '' && !isInvalidated) { return; } setCurrentConfig(finalConfig); setCurrentFetchFunction(() => fetchFunction); setCurrentStateKey(stateKey); const isReturningFromDetail = location.state?.returningFromDetail === true; const existingState = globalPaginatorStates.get(stateKey); if (isReturningFromDetail && existingState) { setBundle(existingState.bundle); setItems(existingState.items); setPages(existingState.pages); setSearchQuery(existingState.searchQuery); setDisplaySearchQuery(existingState.displaySearchQuery); const queryParams = new URLSearchParams(); if (existingState.searchQuery) { queryParams.set(finalConfig.searchParamName!, existingState.searchQuery); } const queryString = queryParams.toString(); const expectedUrl = `${location.pathname}${queryString ? `?${queryString}` : ''}`; if (window.location.href !== window.location.origin + expectedUrl) { navigate(expectedUrl, { replace: true }); } } else { const queryParams = new URLSearchParams(location.search); const searchParam = queryParams.get(finalConfig.searchParamName!) || ''; if (searchParam) { setSearchQuery(searchParam); setDisplaySearchQuery(searchParam); } globalPaginatorStates.delete(stateKey); const loadInitialPage = async () => { const ticket = ++latestLoad.current; setLoading(true); try { const fetchedBundle = await fetchFunction(finalConfig.pageSize!, undefined, searchParam); if (ticket !== latestLoad.current) return; const itemList = unpackBundle(fetchedBundle); setBundle(fetchedBundle); setItems(itemList); setReadFailure(null); saveToGlobalState(fetchedBundle, itemList, [''], searchParam, searchParam); } catch (error) { if (ticket !== latestLoad.current) return; console.error('Failed to load initial page:', error); setReadFailure(sectionReadFailure(error)); } finally { if (ticket === latestLoad.current) setLoading(false); } }; loadInitialPage(); } lastInitializedStateKey.current = stateKey; }, []); const state: PaginatorState = { items, bundle, loading, readFailure, searchQuery, displaySearchQuery, pages, canGoNext: !!getNextPageParam(bundle), canGoPrev: pages.length > 1 }; const actions: PaginatorActions = { search, paginate, refresh, reset, addItem, updateItem, deleteItem, initializeFromUrl }; return { state, actions }; }; const PaginatorServiceContext = createContext | undefined>(undefined); export const PaginatorServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const paginatorService = usePaginatorService(); return ( {children} ); }; export const usePaginatorServiceContext = () => { const context = useContext(PaginatorServiceContext); if (context === undefined) { throw new Error('usePaginatorServiceContext must be used within a PaginatorServiceProvider'); } return context; };