"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { usePathname, useRouter } from "next/navigation"; import { ACTIVE_SITE_CHANNEL, ACTIVE_SITE_KEY, isStorableActiveSite, siteIdFromPathname, } from "../lib/activeSite"; import { setActiveSiteAction } from "../lib/activeSiteActions"; // THE SITE SCOPE, supplied once by the root layout (see app/lib/activeSite.ts). // // `activeSite` is the cookie's selection, resolved by the layout's // readActiveSite() on the server, so the server and the first client render // agree and the picker paints the stored site at once. `stored` below starts // from it and moves ahead of it optimistically when a choice is made; when the // server's value changes (the re-render a cookie write triggers, or a refresh // after another tab chose), it follows that. // // OTHER TABS. The cookie is shared by every tab of this origin, but a tab's // `stored` comes from its root layout, which a client-side navigation does not // re-render: a site picked in one tab left the others showing the old one over // pages that read the new one. So every successful write is announced on a // BroadcastChannel (ACTIVE_SITE_CHANNEL; per origin, so per port, like the // cookie's name), and the other tabs answer with router.refresh(): the layout // and the page re-render with the cookie as it is now, and the tab's router // cache is dropped. A channel does not deliver a message to the instance that // posted it, so a tab does not refresh for its own write. A browser without // BroadcastChannel keeps each tab's value until its next full load. // // Two effects, and neither rewrites a URL: // - visiting a site's own page (/sites//…) records that site, so Dashboard // and Channels follow — the path already shows it, so there is no flash; // - the one-time MIGRATION: a visitor with the old localStorage key and no // cookie gets it copied into the cookie, then the key is removed. That is the // only localStorage read, and the one paint it may cost is on the first visit // after the update. // Both only WRITE the cookie (`record`); the value comes back through the // server's re-render. They run as the page hydrates, and a state change there // would re-render the picker before React replays a choice made on the // server-rendered select before hydration: the re-render resets the select to // its old value, and the replayed change reads that instead of the choice. type SiteScope = { siteIds: string[]; // The stored selection: a site id or "__all__". The picker resolves it // against the path and a `?site=` param; it is not the effective scope alone. stored: string; // Store a choice: shown at once, written through setActiveSiteAction. Resolves // once the cookie is set (and the server has re-rendered with it) to true, or // to false with the previous selection put back when the write failed. choose: (value: string) => Promise; }; const SiteScopeContext = createContext(null); export function SiteScopeProvider({ activeSite, fromCookie, siteIds, children, }: { activeSite: string; // Whether the request carried the cookie at all (the migration's trigger). fromCookie: boolean; siteIds: string[]; children: ReactNode; }) { const [stored, setStored] = useState(activeSite); // Follow the server's value when IT changes — not on every render, so an // optimistic choice is not undone by a refresh that raced the cookie write — // and not while a write is in flight: two quick choices re-render twice, and // the first one's render must not paint over the second choice. const [seen, setSeen] = useState(activeSite); const [writing, setWriting] = useState(0); if (seen !== activeSite) { setSeen(activeSite); if (writing === 0) setStored(activeSite); } // What `stored` is now, for the effects below without making them re-run on // it. Synced after commit (declared first, so it runs before them), and moved // at once by `choose`. const storedRef = useRef(stored); useEffect(() => { storedRef.current = stored; }, [stored]); // The other tabs (see above): subscribe, and keep the one instance to post on. const router = useRouter(); const channel = useRef(null); useEffect(() => { if (typeof BroadcastChannel === "undefined") return; const ch = new BroadcastChannel(ACTIVE_SITE_CHANNEL); channel.current = ch; ch.onmessage = () => router.refresh(); return () => { ch.close(); if (channel.current === ch) channel.current = null; }; }, [router]); const announce = useCallback((value: string) => { try { channel.current?.postMessage(value); } catch { /* closed while unmounting: nothing to tell */ } }, []); const choose = useCallback(async (value: string): Promise => { const previous = storedRef.current; storedRef.current = value; setStored(value); setWriting((n) => n + 1); let ok = false; try { ok = await setActiveSiteAction(value); } catch { ok = false; } setWriting((n) => n - 1); if (ok) { announce(value); } else { storedRef.current = previous; setStored(previous); } return ok; }, [announce]); // Write a value the page did not choose — no optimistic state (see above). const record = useCallback(async (value: string): Promise => { let ok = false; try { ok = await setActiveSiteAction(value); } catch { ok = false; } if (ok) announce(value); return ok; }, [announce]); // Visiting a site's own page records it: once per arrival at that site's // pages (a re-run of the effect — StrictMode, Fast Refresh — does not write // again), and not when the store already holds it. const pathname = usePathname(); const pathSite = siteIdFromPathname(pathname)?.siteId ?? null; const known = pathSite !== null && siteIds.includes(pathSite); const recordedFor = useRef(null); useEffect(() => { if (!known || pathSite === null) { recordedFor.current = null; return; } if (recordedFor.current === pathSite) return; recordedFor.current = pathSite; if (pathSite === storedRef.current) return; void record(pathSite); }, [known, pathSite, record]); // The one-time move from localStorage. Guarded by a ref, not by deps: it is // about this page load, and StrictMode's second mount must not repeat it. const migrated = useRef(false); useEffect(() => { if (migrated.current) return; migrated.current = true; const forget = () => { try { window.localStorage.removeItem(ACTIVE_SITE_KEY); } catch { /* storage unavailable: nothing to forget */ } }; // A cookie already decides, and on a site's page the path does (the effect // above records it): the old key has nothing left to say. if (fromCookie || known) { forget(); return; } let legacy: string | null = null; try { legacy = window.localStorage.getItem(ACTIVE_SITE_KEY); } catch { /* storage unavailable */ } if (!isStorableActiveSite(legacy)) { forget(); return; } // Removed only once the cookie holds it, so a failed write loses nothing. void record(legacy).then((ok) => { if (ok) forget(); }); // Once per page load, on purpose (see the ref). // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const value = useMemo( () => ({ siteIds, stored, choose }), [siteIds, stored, choose], ); return ( {children} ); } export function useSiteScope(): SiteScope { const scope = useContext(SiteScopeContext); if (!scope) { throw new Error("useSiteScope() outside SiteScopeProvider (app/layout.tsx)"); } return scope; }