"use client"; import { MobileNavigationContext, useMobileNavigation } from "./mobile-navigation-context"; import { useMobileLayout } from "./use-mobile-layout"; import { createContext, Suspense, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useTransition, useSyncExternalStore, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAuth } from "./auth-context"; import { createAppTab, deleteAppTab, patchAppTab, moveAppTab } from "./app-tabs-api"; import { AppTabsSession, type AppTabsSnapshot } from "./app-tabs-session"; import { appTabsQueryKey, useAppTabsQuery } from "./use-app-tabs-query"; import { AppTabRouteSync } from "@/components/app-tab-route-sync"; import { appTabsStorageKey } from "./app-tabs-storage"; import { prefetchAppTabDestination, isPreparedAppTabDestination } from "./prefetch-tab-destination"; import { PrBackgroundSync } from "./use-pr-background"; import { createPrTabPreparation } from "./pr-tab-preparation"; import { NavigationContext, useOptionalAppTabNavigation } from "./app-tab-navigation-context"; import { SessionContext, useOptionalAppTabSession } from "./app-tab-session-context"; import { removeLocalSnapshot, restoreLocalSnapshot } from "./local-snapshots"; import { createWindowSelectionPersistence } from "./window-selection-persistence"; import { createRestoredTabStartupAdmission } from "./restored-tab-startup-admission"; import { INITIAL_ACCOUNT_CACHE_SCOPE, nextAccountCacheScope } from "./account-query-provider"; import { beginPendingNavigation } from "./pending-navigation"; import { shortenLegacyAppUrl } from "./app-urls"; interface AppTabsValue extends AppTabsSnapshot { session: AppTabsSession; loading: boolean; loadError: boolean; reload: () => void; } const Context = createContext(null); // Persistence and tab-list changes belong to the strip. Pages only need the // active tab, and action handlers need the stable account session. // `NavigationContext` itself lives in ./app-tab-navigation-context (imported // here and re-exported below) so lower-level modules can read it without a // dependency cycle through this provider. const emptySnapshot = () => null; const noSubscription = () => () => {}; type QueryState = { session: AppTabsSession; loading: boolean; loadError: boolean; reload: () => void }; export function AppTabsProvider({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); const mobile = useMobileLayout(); const client = useQueryClient(); const router = useRouter(); const previous = useRef<{ owner: string; mobile: boolean } | null>(null); const freshSession = !previous.current || previous.current.owner !== user?.id; const resumeAtCurrentRoute = !freshSession && previous.current?.mobile === true; const owner = user?.id; // Like the account query cache: the first auth resolution keeps the tree // mounted before it, and every later identity change remounts it. const [scope, setScope] = useState(INITIAL_ACCOUNT_CACHE_SCOPE); const nextScope = nextAccountCacheScope(scope, loading, owner ?? null); if (nextScope !== scope) setScope(nextScope); const desktop = mobile === false && !!owner; // Only the desktop controller owns account queries, restoration and writes. const session = useMemo(() => desktop && owner ? createAccountSession(owner, client) : null, [desktop, owner, client]); const snapshot = useSyncExternalStore(session?.subscribe ?? noSubscription, session?.getSnapshot ?? emptySnapshot, session?.getSnapshot ?? emptySnapshot); const [queryState, setQueryState] = useState(null); const [navigationPending, startNavigation] = useTransition(); const tc = useTranslations("Common"); const mobileNavigation = useMemo(() => { const guards = new Set<() => Promise>(); let pending = false; return { registerDeparture: (guard: () => Promise) => { guards.add(guard); return () => { guards.delete(guard); }; }, open: (navigate: () => void) => { if (pending) return; pending = true; startNavigation(async () => { try { for (const guard of guards) if (!(await guard())) return; startNavigation(navigate); } catch (error) { console.error("Unable to save before navigation", error); } finally { pending = false; } }); }, }; }, [user?.id, startNavigation]); useEffect(() => { if (!user) { previous.current = null; return; } if (mobile === undefined) return; const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; // Reload-to-Home belongs to a fresh mobile session, never a rotation. if (mobile && freshSession && navigation?.type === "reload" && window.location.pathname !== "/home") router.replace("/home"); previous.current = { owner: user.id, mobile }; }, [user, mobile, freshSession, router]); const value = useMemo(() => session && snapshot ? { ...snapshot, session, loading: queryState?.session === session ? queryState.loading : true, loadError: queryState?.session === session ? queryState.loadError : false, reload: queryState?.session === session ? queryState.reload : () => {}, } : null, [session, snapshot, queryState]); const navigation = useMemo(() => session ? { session, activeId: snapshot?.activeId ?? null } : null, [session, snapshot?.activeId]); // Keep the provider topology and descendant drafts stable across layout changes. return {session && owner ? : null} {session ? : null} {session ? : null} {owner && mobile && navigationPending &&
} {children} ; } /** Run after the next paint. A hidden document paints nothing: wait one task. */ function afterPaint(work: () => void): () => void { let frame: number | undefined; let timer: ReturnType | undefined; if (typeof requestAnimationFrame === "function" && document.visibilityState === "visible") { frame = requestAnimationFrame(() => { frame = undefined; timer = setTimeout(work, 0); }); } else timer = setTimeout(work, 0); return () => { if (frame !== undefined) cancelAnimationFrame(frame); clearTimeout(timer); }; } /** How long a successful write waits for its own realtime echo before reading the list itself. */ const ECHO_FALLBACK_MS = 2_000; function createAccountSession(owner: string, client: QueryClient) { const key = appTabsQueryKey(owner); const abort = new AbortController(); let fallback: ReturnType | undefined; async function write(operation: () => Promise): Promise { const interrupted = client.isFetching({ queryKey: key }) > 0; await client.cancelQueries({ queryKey: key }); const before = client.getQueryState(key); let succeeded = false; try { const result = await operation(); succeeded = true; return result; } finally { const after = client.getQueryState(key); // A read cancelled here, or one that ran during the write, may hold the // rows from before it: supersede it now. Otherwise the session already // applied the acknowledgement and the write's own broadcast refetches // the list; reading it here too only raced that echo. const overlapped = interrupted || !before || !after || after.fetchStatus !== "idle" || after.isInvalidated || after.dataUpdatedAt !== before.dataUpdatedAt || after.errorUpdatedAt !== before.errorUpdatedAt; if (!succeeded || overlapped) void client.invalidateQueries({ queryKey: key }); else { // The trigger may drop its broadcast, or the user channel may be down. const acknowledgedAt = Date.now(); clearTimeout(fallback); fallback = setTimeout(() => { const state = client.getQueryState(key); if (abort.signal.aborted || !state || state.fetchStatus !== "idle" || state.dataUpdatedAt >= acknowledgedAt) return; void client.invalidateQueries({ queryKey: key }); }, ECHO_FALLBACK_MS); } } } const controller = new AppTabsSession(owner, { create: (ensure, id) => write(() => createAppTab(ensure, id, abort.signal)), patch: (tab, patch) => write(() => patchAppTab(tab, patch, abort.signal)), close: (tab) => write(() => deleteAppTab(tab, abort.signal)), move: (tab, beforeId) => write(() => moveAppTab(tab, beforeId, abort.signal)), }); controller.onDispose = () => { abort.abort(); clearTimeout(fallback); }; return controller; } function AccountTabSync({ owner, session, resumeAtCurrentRoute, onQueryState }: { owner: string; session: AppTabsSession; resumeAtCurrentRoute: boolean; onQueryState: (state: QueryState) => void; }) { const keepCurrentRoute = useRef(resumeAtCurrentRoute).current; const client = useQueryClient(); const router = useRouter(); const query = useAppTabsQuery(owner); const storageKey = appTabsStorageKey(owner); const lifetime = useMemo(() => { let storage: Storage | undefined; try { if (typeof window !== "undefined") storage = window.sessionStorage; } catch { /* Window selection persistence is optional. */ } return { active: false, writer: createWindowSelectionPersistence(storage, storageKey, owner) }; }, [session, storageKey, owner]); const startupAdmission = useMemo(() => createRestoredTabStartupAdmission(client), [client, owner, session]); const preparationRef = useRef | null>(null); useLayoutEffect(() => startupAdmission.mount(), [startupAdmission]); useEffect(() => { if (query.isError && !query.isFetching) startupAdmission.fail(); }, [query.isError, query.isFetching, startupAdmission]); useLayoutEffect(() => { onQueryState({ session, loading: query.isPending, loadError: query.isError, reload: () => { void query.refetch(); } }); }, [session, query.isPending, query.isError, query.refetch, onQueryState]); useEffect(() => { const preparation = createPrTabPreparation(client); preparationRef.current = preparation; // `published`: the destination preparation protects; `selected`: it was // published without the full pass that ranks and rebuilds the working set. let previous = "", published = "", selected = false; let cancel: (() => void) | undefined; const current = () => { const state = session.getSnapshot(); const active = state.tabs.find((tab) => tab.id === state.activeId); if (!active) return null; const hrefs = state.tabs.map((tab) => tab.href); return { href: active.href, hrefs, identity: JSON.stringify([active.id, active.href, hrefs]) }; }; const visit = () => { cancel = undefined; const next = current(); if (!next || (next.identity === previous && !selected)) return; previous = next.identity; published = next.href; selected = false; startupAdmission.handoff(() => preparation.visit(next.href, next.hrefs)); }; // The startup visit hands admission over synchronously. After that, a // switch publishes its destination at once, before the incoming view's // foreground reads start, and the full pass follows its paint. const changed = () => { if (!previous) { visit(); return; } const next = current(); if (!next || (next.identity === previous && !selected)) return; if (next.href !== published) { published = next.href; selected = true; preparation.select(next.href); } cancel ??= afterPaint(visit); }; const stop = session.subscribe(changed); visit(); return () => { stop(); cancel?.(); preparation.dispose(); if (preparationRef.current === preparation) preparationRef.current = null; }; }, [client, session, startupAdmission]); useEffect(() => { lifetime.active = true; return () => { lifetime.active = false; // Effect replay retains this exact writer. A replacement session has a // separate lifetime and cannot keep the retired account's writer alive. queueMicrotask(() => { if (lifetime.active) return; lifetime.writer.dispose(); session.dispose(); }); }; }, [session, lifetime]); useEffect(() => { // One warmup attempt per href (tab label, palette row): prefetchQuery // itself respects staleTime, so a fresh cache costs nothing anyway. const attempted = new Set(); session.prefetch = (href) => { // A local surface already owns this route. Preparing its server entry // again can reconstruct an unused tree while the user activates it. const retained = session.getSnapshot().tabs.some((tab) => tab.href === href && session.isRetainedDestination(tab.id, href)); if (!retained && !isPreparedAppTabDestination(client, href)) router.prefetch(href); // The hover's own prefetches must not cancel this destination's // in-flight preparation reads, only other destinations' reads. preparationRef.current?.intend(href); prefetchAppTabDestination(client, href, attempted); }; session.navigate = (href) => { const current = window.location.pathname; const next = href.split(/[?#]/)[0]; const wikiRoot = current.match(/^\/projects\/[^/]+\/pages(?:\/|$)/)?.[0].replace(/\/$/, ""); // A stored destination may carry full ids (`?open=`). The address // uses short codes: the proxy would answer a full id with a 308, and that // redirected response, racing the page's own URL update, ended the // navigation in a full reload, replayed on every load (MIN-875). const url = new URL(href, window.location.origin); const shortened = shortenLegacyAppUrl(url.pathname, url.search); const address = shortened ? shortened + url.hash : href; if (session.isRetainedDestination(session.getSnapshot().activeId, href) || isPreparedAppTabDestination(client, href) || (wikiRoot && (next === wikiRoot || next.startsWith(`${wikiRoot}/`)))) window.history.pushState(null, "", address); else { // A cold destination waits on the router: the shell shows it now. beginPendingNavigation(address); router.push(address, { scroll: false }); } }; session.remember = (id, href) => { if (lifetime.active) lifetime.writer.remember(id, href); }; }, [router, session, client, lifetime]); useEffect(() => { if (!query.data) return; session.receive(query.data); // An empty canonical scope has no restored working set to reserve. Its // ordinary first-tab creation must not hold optional admission indefinitely. if (!session.getSnapshot().tabs.length) startupAdmission.fail(); // Server tab reconciliation is ongoing; the window snapshot is startup-only. // Reopening it after each location PATCH repeats authorization/decryption // even though initialize already has an active destination and does nothing. if (session.getSnapshot().activeId) return; let cancelled = false; void (async () => { let restored: { id: string; href: string } | undefined; try { if (!keepCurrentRoute) { const raw = JSON.parse(sessionStorage.getItem(storageKey) ?? "null"); if (raw?.format !== "minddy-local-v1") removeLocalSnapshot(sessionStorage, storageKey); else { const value = await restoreLocalSnapshot(sessionStorage, storageKey, "window-tabs"); if (value && typeof value === "object" && "id" in value && "href" in value && typeof value.id === "string" && typeof value.href === "string") restored = { id: value.id, href: value.href }; } } } catch { /* Server-backed tab destinations remain available. */ } if (!cancelled && lifetime.active) { try { await session.initialize(window.location.pathname + window.location.search + window.location.hash, restored, keepCurrentRoute); } finally { // initialize reports normal failures on the session and resolves. // An empty/failed startup still releases optional admission. if (!cancelled && lifetime.active && !session.getSnapshot().activeId) startupAdmission.fail(); } } })(); return () => { cancelled = true; }; }, [query.data, session, storageKey, keepCurrentRoute, lifetime, startupAdmission]); // Flush the newest location and encrypted window selection on a real hide. // Unload cannot guarantee receipt of the optional snapshot's final ACK. useEffect(() => { const onHide = () => { if (!lifetime.active) return; session.onPageHide(); void lifetime.writer.flush(); }; const onVisibility = () => { if (document.visibilityState === "hidden") onHide(); }; window.addEventListener("pagehide", onHide); document.addEventListener("visibilitychange", onVisibility); return () => { window.removeEventListener("pagehide", onHide); document.removeEventListener("visibilitychange", onVisibility); }; }, [session, lifetime]); return null; } export const useOptionalAppTabs = () => useContext(Context); export { useOptionalAppTabNavigation }; export { useOptionalAppTabSession }; /** A retained board keeps its tab's local filters while another tab is active. */ export function AppTabNavigationScope({ activeId, children }: { activeId: string | null; children: ReactNode }) { const session = useOptionalAppTabSession(); const value = useMemo(() => session ? { session, activeId } : null, [session, activeId]); return {children}; } export function useAppTabs() { const value = useOptionalAppTabs(); if (!value) throw new Error("useAppTabs requires AppTabsProvider"); return value; } /** Every mounted editor, including database previews, participates in departure. */ export function useAppTabDeparture(guard: () => Promise) { const session = useOptionalAppTabSession(); const mobile = useMobileNavigation(); useEffect(() => session?.registerDeparture(guard) ?? mobile?.registerDeparture(guard), [session, mobile, guard]); }