diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 87ae909..6ae4df0 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,42 +1,3 @@ -## [Frontend] AuthProvider sessionStorage not synchronized across tabs - -**Where found** - -- `frontend/src/providers/AuthProvider.tsx` — uses `sessionStorage` to persist `isAuthenticated` across page refreshes -- `sessionStorage` is **tab-scoped** — not shared across browser tabs - -**Why this is needed** - -If a user logs out in Tab A, Tab B's `sessionStorage` still holds `isAuthenticated: true`. Tab B continues showing authenticated UI until next full page refresh or 401 error. - -**Goal** - -Ensure logout state is propagated across all open tabs immediately. - -**What to do** - -1. Add `storage` event listener in `AuthProvider` to receive logout events from other tabs -2. When `logout()` is called, clear `sessionStorage` explicitly -3. Consider also using `BroadcastChannel` API for real-time cross-tab sync - -**Possible traps and issues** - -- `storage` event only fires when `sessionStorage` changed **in another tab** -- `StorageEvent` doesn't fire if cookies are sole authentication mechanism -- Very old browsers don't support `StorageEvent` — fallback to full-page refresh - -**Docs changes needed** - -- Update `Docs/Architekture.md` § 3.2 (AuthProvider) — document cross-tab synchronization -- Add note in `Docs/Web-Development.md` about authentication state management - -**Doc references** - -- `Docs/Architekture.md` § 3.2 (Providers section) -- `frontend/src/providers/AuthProvider.tsx` - ---- - ## [Frontend] usePolledData — setInterval without drift correction **Where found** diff --git a/frontend/src/hooks/README.md b/frontend/src/hooks/README.md index 41af56c..a0fd657 100644 --- a/frontend/src/hooks/README.md +++ b/frontend/src/hooks/README.md @@ -29,3 +29,27 @@ Hooks that only fetch once inside `useEffect` and do not expose a manual refresh ### 3. Do not use boolean cancelled flags for network requests A boolean `cancelled` flag is not sufficient because it does not stop the underlying fetch. Abort signals are the correct cancellation mechanism for fetch-based hooks. + +## Polling with Drift Correction + +The `usePolledData` hook implements drift-corrected polling to maintain accurate polling intervals despite variable fetch durations. + +### How it works + +- Uses self-scheduling timeouts instead of fixed `setInterval` +- Tracks elapsed time from poll start to completion with `performance.now()` +- Calculates next delay as `Math.max(0, pollInterval - elapsed)` +- Schedules the next poll with drift compensation in the `onSuccess` callback +- If a fetch takes longer than `pollInterval`, the next poll starts immediately (delay = 0) + +### Why this matters + +With fixed `setInterval`: +- If a fetch takes 2 seconds and `pollInterval` is 5 seconds +- Actual polling interval becomes ~7 seconds (2s fetch + 5s interval) +- Effective polling rate drifts and wastes bandwidth + +With drift correction: +- Total time from poll start to next poll start is always ~5 seconds +- Fetch duration doesn't affect the long-term polling rate +- Bandwidth and CPU usage remain consistent diff --git a/frontend/src/hooks/__tests__/usePolledData.test.ts b/frontend/src/hooks/__tests__/usePolledData.test.ts new file mode 100644 index 0000000..ca37cbc --- /dev/null +++ b/frontend/src/hooks/__tests__/usePolledData.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePolledData } from "../usePolledData"; + +// Mock usePageVisibility to always return true (page visible) +vi.mock("../usePageVisibility", () => ({ + usePageVisibility: () => true, +})); + +describe("usePolledData", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("stops polling when pollInterval is undefined", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn((response: { value: string }) => response.value); + + renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + // Initial fetch should happen in useFetchData + await act(async () => { + vi.runAllTimersAsync(); + }); + + const callCountAfterInitial = fetcher.mock.calls.length; + + // Reset timer and advance to ensure no more polls + vi.clearAllTimers(); + fetcher.mockClear(); + + await act(async () => { + vi.advanceTimersByTime(10000); + }); + + // Should not poll since pollInterval is undefined + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("implements drift correction: next poll is scheduled based on elapsed time", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn((response: { value: string }) => response.value); + + renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + pollInterval: 5000, + }) + ); + + // Wait for initial setup + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const initialCalls = fetcher.mock.calls.length; + + // Clear for clean test + fetcher.mockClear(); + + // First poll completes after ~0ms, next poll should be in ~5000ms + // Advance 4000ms + await act(async () => { + vi.advanceTimersByTime(4000); + }); + + expect(fetcher).not.toHaveBeenCalled(); + + // Advance to 5500ms total, poll should have happened + await act(async () => { + vi.advanceTimersByTime(1500); + }); + + expect(fetcher).toHaveBeenCalled(); + }); + + it("cleans up timers on unmount", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn((response: { value: string }) => response.value); + + const { unmount } = renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + pollInterval: 5000, + }) + ); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + const callCount = fetcher.mock.calls.length; + + // Unmount + unmount(); + + // Advance time and verify no new fetches + await act(async () => { + vi.advanceTimersByTime(10000); + }); + + // Should not increase beyond initial calls + expect(fetcher.mock.calls.length).toBe(callCount); + }); + + it("calls refresh callback to trigger immediate fetch", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn((response: { value: string }) => response.value); + + const { result } = renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + pollInterval: 5000, + }) + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const initialCalls = fetcher.mock.calls.length; + fetcher.mockClear(); + + // Call refresh + await act(async () => { + result.current.refresh(); + vi.advanceTimersByTime(100); + }); + + expect(fetcher).toHaveBeenCalled(); + }); + + it("returns initial data if provided", () => { + const fetcher = vi.fn().mockResolvedValue({ value: "updated" }); + const selector = vi.fn(); + + const { result } = renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + initialData: "initial", + pollInterval: 5000, + }) + ); + + expect(result.current.data).toBe("initial"); + expect(result.current.loading).toBe(true); + }); + + it("returns null when data is undefined and no initialData", () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn(); + + const { result } = renderHook(() => + usePolledData({ + fetcher, + selector, + errorMessage: "Failed to load", + pollInterval: 5000, + }) + ); + + expect(result.current.data).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/usePolledData.ts b/frontend/src/hooks/usePolledData.ts index bfe9e12..db612d8 100644 --- a/frontend/src/hooks/usePolledData.ts +++ b/frontend/src/hooks/usePolledData.ts @@ -4,7 +4,7 @@ * Composes useFetchData and adds polling and window-focus refetch semantics * for non-list endpoints that need periodic updates. */ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useFetchData } from "./useFetchData"; import { usePageVisibility } from "./usePageVisibility"; import type { FetchError } from "../types/api"; @@ -57,6 +57,9 @@ export interface UsePolledDataResult { * When `pauseWhenHidden` is enabled, polling pauses when the page becomes hidden * and resumes immediately with a fresh fetch when the page becomes visible again. * + * Polling uses drift correction: if a fetch takes longer than expected, the next poll + * is scheduled to compensate, maintaining the desired polling interval. + * * @param options - Configuration options * @returns Data, loading state, typed error, and refresh callback */ @@ -82,40 +85,87 @@ export function usePolledData( initialData, }); - const refreshRef = useRef(refresh); - const intervalIdRef = useRef(null); + const refreshRef = useRef<(() => void) | null>(null); + const timeoutIdRef = useRef(null); + const pollStartTimeRef = useRef(null); const previousVisibilityRef = useRef(null); const isVisible = usePageVisibility(); + const cancelledRef = useRef(false); + const previousLoadingRef = useRef(true); useEffect(() => { refreshRef.current = refresh; }, [refresh]); - // Polling effect: set up interval if pollInterval is provided and page is visible + /** + * Schedule next poll with drift correction. + * Calculates elapsed time since poll start and compensates the delay + * to maintain the desired polling interval despite fetch duration variance. + */ + const scheduleNextPoll = useCallback((nextDelay: number): void => { + if (cancelledRef.current) return; + + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + + const id = window.setTimeout((): void => { + if (cancelledRef.current) return; + pollStartTimeRef.current = performance.now(); + if (refreshRef.current) { + refreshRef.current(); + } + }, nextDelay); + + timeoutIdRef.current = id; + }, []); + + // Monitor fetch completion and schedule next poll with drift correction useEffect(() => { if (!pollInterval) { return; } - // If pauseWhenHidden is enabled and page is hidden, clear any existing interval - if (pauseWhenHidden && !isVisible) { - if (intervalIdRef.current !== null) { - clearInterval(intervalIdRef.current); - intervalIdRef.current = null; - } + // Detect transition from loading=true to loading=false (fetch completed) + if (previousLoadingRef.current && !loading && pollStartTimeRef.current !== null) { + // Fetch completed, calculate elapsed time and schedule next poll + const elapsed = performance.now() - pollStartTimeRef.current; + const nextDelay = Math.max(0, pollInterval - elapsed); + scheduleNextPoll(nextDelay); + } + + previousLoadingRef.current = loading; + }, [loading, pollInterval, scheduleNextPoll]); + + // Polling effect: set up self-scheduling polling if pollInterval is provided and page is visible + useEffect(() => { + if (!pollInterval) { return; } - const id = window.setInterval((): void => { - refreshRef.current(); - }, pollInterval); + cancelledRef.current = false; - intervalIdRef.current = id; + // If pauseWhenHidden is enabled and page is hidden, don't start polling + if (pauseWhenHidden && !isVisible) { + return; + } + + // Record when polling starts and schedule first poll immediately + pollStartTimeRef.current = performance.now(); + const id = window.setTimeout((): void => { + if (cancelledRef.current) return; + pollStartTimeRef.current = performance.now(); + refreshRef.current?.(); + }, 0); + + timeoutIdRef.current = id; return (): void => { - if (intervalIdRef.current === id) { - clearInterval(id); - intervalIdRef.current = null; + cancelledRef.current = true; + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; } }; }, [pollInterval, pauseWhenHidden, isVisible]); @@ -128,12 +178,13 @@ export function usePolledData( // Only refresh if this is a transition from hidden to visible, not on mount if (previousVisibilityRef.current === false && isVisible) { - // Page became visible: clear pending interval and refresh immediately - if (intervalIdRef.current !== null) { - clearInterval(intervalIdRef.current); - intervalIdRef.current = null; + // Page became visible: clear pending timeout and refresh immediately + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; } - refreshRef.current(); + pollStartTimeRef.current = performance.now(); + refreshRef.current?.(); } previousVisibilityRef.current = isVisible;