lib/app-tabs-context.tsx
Rama main · réplica de lectura del repositorio original.
"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<AppTabsValue | null>(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<QueryState | null>(null);
const [navigationPending, startNavigation] = useTransition();
const tc = useTranslations("Common");
const mobileNavigation = useMemo(() => {
const guards = new Set<() => Promise<boolean>>();
let pending = false;
return {
registerDeparture: (guard: () => Promise<boolean>) => { 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 <SessionContext.Provider key={nextScope.key} value={session}><NavigationContext.Provider value={navigation}>
<Context.Provider value={value}><MobileNavigationContext.Provider value={owner && mobile === true ? mobileNavigation : null}>
{session && owner ? <AccountTabSync key={owner} owner={owner} session={session} resumeAtCurrentRoute={resumeAtCurrentRoute} onQueryState={setQueryState} /> : null}
{session ? <PrBackgroundSync /> : null}
{session ? <Suspense fallback={null}><AppTabRouteSync /></Suspense> : null}
{owner && mobile && navigationPending && <div role="progressbar" aria-label={tc("loading")}
className="mobile-navigation-progress fixed inset-x-0 top-0 z-[150] h-0.5 overflow-hidden bg-primary/15">
<div className="h-full w-1/3 bg-primary" />
</div>}
{children}
</MobileNavigationContext.Provider></Context.Provider>
</NavigationContext.Provider></SessionContext.Provider>;
}
/** 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<typeof setTimeout> | 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<typeof setTimeout> | undefined;
async function write<T>(operation: () => Promise<T>): Promise<T> {
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<ReturnType<typeof createPrTabPreparation> | 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<string>();
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=<uuid>`). 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 <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
}
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<boolean>) {
const session = useOptionalAppTabSession();
const mobile = useMobileNavigation();
useEffect(() => session?.registerDeparture(guard) ?? mobile?.registerDeparture(guard), [session, mobile, guard]);
}