Ctrl K
ComunidadGuías…

lib/app-tabs-context.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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]);
}