"use client"; import { useEffect, useRef, useTransition } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { configurePulse, getRevCounter, subscribePulse } from "./pulse"; type Props = { // Refresh cadence in milliseconds. 0 (or negative) disables passive refresh. intervalMs: number; }; // Routes that display nothing a server re-render could update. The monitor // widget is embedded chrome-less in a pinned window and re-fetches every // payload it shows on its own cadence, so a tree refresh there is pure waste. // // The dashboard ("/") is a deliberate NON-member, despite also polling its own // endpoints: the sidebar's admin title is rendered by the ROOT LAYOUT from // settings.json, and a tree refresh is the only thing that can update it. // Excluding "/" would silently break that — it is the exact behaviour // e2e/auto-refresh.spec.ts pins ("re-fetches server data on the interval // without a reload", asserted on "/"). The cost of leaving it in is now // approximately nothing anyway: a refresh only happens when the pulse token // has actually moved, so an idle dashboard never re-renders either way. // // A predicate rather than an allow-list of every route, so a NEW page defaults // to refreshing (the safe direction) instead of silently going stale. function selfPolling(pathname: string): boolean { return pathname.startsWith("/widget"); } // Don't START a tree refresh within this long of a navigation. A refresh // landing on top of an in-flight navigation fights it for the same router // state, and the user sees the destination flicker or stall. const NAV_QUIET_MS = 1000; // Mounted once globally in the root layout. // // It used to call router.refresh() unconditionally on a timer: a full // server-side re-render of every page, every 5 seconds, on every route, whether // or not anything had changed. Against the real corpus that was ~4.9 s of work // per tick and held next-server at ~22% of a core forever with one idle tab // open. // // Now it asks /api/pulse — an in-memory change token, no corpus contact — and // only refreshes when the token actually moves. At idle that is one small // request per interval and ZERO renders. // // The user-facing contract is deliberately unchanged: same setting, same 5 s // default, same 0-disables semantics. Editing settings.json still repaints, // because the settings file's mtime is part of the token. export function AutoRefresh({ intervalMs }: Props) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); // Read through refs inside the subscription so it doesn't have to re-subscribe // (and restart the shared poller) on every transition or route change. const isPendingRef = useRef(isPending); isPendingRef.current = isPending; const refreshEnabledRef = useRef(true); refreshEnabledRef.current = !selfPolling(pathname); // Timestamp of the last navigation, so a refresh never collides with one. const lastNavAt = useRef(0); useEffect(() => { lastNavAt.current = Date.now(); }, [pathname, searchParams]); useEffect(() => { configurePulse(intervalMs); }, [intervalMs]); useEffect(() => { if (!Number.isFinite(intervalMs) || intervalMs <= 0) return; // Only fires when the pulse token actually changed. let seen = getRevCounter(); const unsubscribe = subscribePulse(() => { const counter = getRevCounter(); if (counter === seen) return; // nothing changed — nothing to re-render // Every guard below RETURNS WITHOUT ADVANCING `seen`, so a change that is // deferred is retried on the next tick rather than swallowed. Advancing // the marker before the guards would drop the update permanently — e.g. a // settings edit landing during the post-navigation quiet window would // simply never be applied. if (!refreshEnabledRef.current) { // This route doesn't use the tree refresh at all, so there is nothing // to defer to — consume it and move on. seen = counter; return; } if (isPendingRef.current) return; // a refresh is already settling // Keep polling during the quiet window (so the token stays current) but // don't start a refresh on top of an in-flight navigation. if (Date.now() - lastNavAt.current < NAV_QUIET_MS) return; seen = counter; startTransition(() => router.refresh()); }); return unsubscribe; // `router` is stable; startTransition is stable. }, [intervalMs, router]); return null; }