import { createHomeTab, moveAppTabBefore, reconcileAppTabs, selectTabAfterClose, sortAppTabs, type AppTab, type AppTabPatch } from "./app-tabs"; import { appTabDestinationKey, normalizeAppTabLocation, sameAppTabDestination } from "./app-tab-location"; import { AppTabRequestError } from "./app-tabs-api"; /** Whether `url` reopens the remembered destination's page while carrying no * selection of its own: same path, and every URL param or anchor already * present in the remembered href. An anchor the memory LACKS is not a * selection of its own either — in-page anchors are sub-selections a previous * session may not have published — only two DIFFERENT anchors are two * destinations. */ function reopensRemembered(url: string, remembered: string): boolean { const parts = (href: string) => { const [body = "", ...hash] = destinationKey(href).split("#"); const [path = "", query = ""] = body.split("?"); return { path, params: new URLSearchParams(query), hash: hash.join("#") }; }; const from = parts(url); const to = parts(remembered); if (from.path !== to.path) return false; if (from.hash && to.hash && from.hash !== to.hash) return false; for (const [key, value] of from.params) { if (to.params.get(key) !== value) return false; } return true; } /** The destination an href names. The address carries short codes while a * stored row or the window's memory may still hold full ids (MIN-875): raw, * the two read as different pages and a reload claimed a duplicate row. */ function destinationKey(href: string): string { return appTabDestinationKey(href) ?? href; } export interface AppTabsTransport { create: (ensure: boolean, id: string) => Promise; patch: (tab: AppTab, patch: AppTabPatch) => Promise; close: (tab: AppTab) => Promise; move: (tab: AppTab, beforeId: string | null) => Promise; } export interface AppTabsSnapshot { tabs: AppTab[]; activeId: string | null; busy: boolean; error: string | null; recovering: boolean; } /** One window's activation, departure guards, and ordered account mutations. */ export class AppTabsSession { private snapshot: AppTabsSnapshot = { tabs: [], activeId: null, busy: false, error: null, recovering: false }; private listeners = new Set<() => void>(); private guards = new Set<() => Promise>(); private queue: Promise = Promise.resolve(); private writes: Promise = Promise.resolve(); private creating = new Set(); private closing = new Map(); private locations = new Map(); /** The newest server row seen per tab, so an unchanged location skips its write. */ private confirmed = new Map(); private order: { id: string; beforeId: string | null }[] = []; private orderBase: Map | null = null; private closed = new Set(); private disposed = false; private observedHref: string | null = null; private activeHref: string | null = null; private target: string | null = null; private timer: ReturnType | undefined; private targetTimer: ReturnType | undefined; private localState = new Map(); private startup: { href: string; restored?: { id: string; href: string }; keepCurrentRoute: boolean } | null = null; getLocalState(key: string): T | undefined { return this.localState.get(key) as T | undefined; } setLocalState(key: string, value: unknown) { if (!this.disposed) this.localState.set(key, value); } private forget(id: string) { this.closed.add(id); this.locations.delete(id); this.confirmed.delete(id); for (const key of this.localState.keys()) if (key.startsWith(`${id}:`)) this.localState.delete(key); } getActiveHref = () => this.activeHref; navigate: (href: string) => void = () => {}; /** Owned by the mounted view host; only an exact retained destination qualifies. */ isRetainedDestination: (tabId: string | null, href: string) => boolean = () => false; prefetch: (href: string) => void = () => {}; remember: (id: string, href: string) => void = () => {}; onDispose: () => void = () => {}; constructor(private owner: string, private transport: AppTabsTransport) {} getSnapshot = () => this.snapshot; subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; private emit(patch: Partial) { if (this.disposed) return; if (patch.tabs) for (const move of this.order) patch.tabs = moveAppTabBefore(patch.tabs, move.id, move.beforeId); if (patch.tabs) patch.tabs = sortAppTabs(patch.tabs); this.snapshot = { ...this.snapshot, ...patch }; this.listeners.forEach((listener) => listener()); } registerDeparture = (guard: () => Promise) => { this.guards.add(guard); return () => { this.guards.delete(guard); }; }; private async saveEditors(): Promise { for (const guard of this.guards) { if (!(await guard())) { this.emit({ error: "save_failed" }); return false; } } return !this.disposed; } private merge(tab: AppTab) { if (this.closed.has(tab.id) || this.disposed) return; if (this.closing.has(tab.id)) { const previous = this.closing.get(tab.id)!; if (tab.revision >= previous.revision) this.closing.set(tab.id, tab); return; } const previous = this.snapshot.tabs.find((row) => row.id === tab.id); if (previous && previous.revision > tab.revision) return; const href = this.locations.get(tab.id); this.emit({ tabs: sortAppTabs([...this.snapshot.tabs.filter((row) => row.id !== tab.id), href ? { ...tab, href } : tab]) }); } private confirm(tab: AppTab) { const known = this.confirmed.get(tab.id); if (this.closed.has(tab.id) || (known && known.revision > tab.revision)) return; this.confirmed.set(tab.id, { href: normalizeAppTabLocation(tab.href) ?? "/home", revision: tab.revision }); } /** Merges a row the server returned. */ private adopt(tab: AppTab) { this.confirm(tab); this.merge(tab); } private report(error: unknown) { if (error instanceof AppTabRequestError && error.tab) this.adopt(error.tab); this.emit({ error: error instanceof AppTabRequestError ? error.code : "database" }); } /** Network latency must not hold the window's navigation queue. */ private persist(action: () => Promise): Promise { const next = this.writes.then(async () => { if (this.disposed) return; try { await action(); } catch (error) { this.report(error); } }); this.writes = next; return next; } /** `departureOnly`: the action awaits nothing but departure guards. Without * guards it settles within the current task, before any input can be * dropped, so it skips the two busy renders. */ private run(action: () => Promise, departureOnly = false): Promise { const next = this.queue.then(async () => { if (this.disposed) return; const busy = !departureOnly || this.guards.size > 0; if (busy) this.emit({ busy: true, error: null }); else if (this.snapshot.error !== null) this.emit({ error: null }); try { await action(); } catch (error) { this.report(error); } finally { if (busy) this.emit({ busy: false }); } }); this.queue = next; return next; } private active() { return this.snapshot.tabs.find((tab) => tab.id === this.snapshot.activeId); } /** Remote edits update rows without changing this window's route. */ receive(rows: AppTab[]) { if (this.orderBase) this.orderBase = new Map(rows.map((row) => [row.id, row.position])); const canonical = reconcileAppTabs(rows, this.owner); for (const tab of canonical) { this.confirm(tab); if (this.closing.has(tab.id)) this.merge(tab); } const incoming = canonical.filter((tab) => !this.closed.has(tab.id) && !this.closing.has(tab.id)); for (const tab of this.snapshot.tabs) { if (this.creating.has(tab.id) && !incoming.some((row) => row.id === tab.id)) incoming.push(tab); } const active = this.active(); const missing = active && !incoming.some((tab) => tab.id === active.id); const currentById = new Map(this.snapshot.tabs.map((tab) => [tab.id, tab])); const tabs = incoming.map((tab) => { const current = currentById.get(tab.id); const canonical = current && current.revision > tab.revision ? current : tab; const href = this.locations.get(tab.id); return href ? { ...canonical, href } : canonical; }); if (missing) tabs.push(active); this.emit({ tabs: sortAppTabs(tabs), recovering: Boolean(missing) }); if (missing) void this.recoverRemoteClose(); } initialize(href: string, restored?: { id: string; href: string }, keepCurrentRoute = false): Promise { if (this.snapshot.activeId) return Promise.resolve(); if (keepCurrentRoute) restored = undefined; this.startup = { href, restored, keepCurrentRoute }; return this.run(async () => { if (this.snapshot.activeId) return; if (!this.snapshot.tabs.length) this.adopt(await this.transport.create(true, crypto.randomUUID())); const normalized = normalizeAppTabLocation(href) ?? "/home"; const explicit = keepCurrentRoute || href !== "/home"; // The load destination claims the row that CONVENTIONALLY ALREADY shows // it: a live reload (the URL still points at the tab the previous // session left open) restores that tab, a typed or shared deep link // reuses a tab that already displays it. An address NO row displays — // the previous document died before it could observe it, or the memory // was lost — claims a FRESH row: defaulting to `tabs[0]` is what ground // the first pinned tab's location under the load URL — the home tab // turned into a replica of the current page. const restoredHref = restored ? normalizeAppTabLocation(restored.href) : null; let restoredTab = restored ? this.snapshot.tabs.find((tab) => tab.id === restored.id) ?? null : null; if (!restoredTab && restored && restoredHref && reopensRemembered(normalized, restoredHref)) { // The remembered row was closed elsewhere (another window, another // device): the row that still displays the remembered page stands in // for it, so a refresh restores THAT row instead of grinding the // first one under the load URL. restoredTab = this.snapshot.tabs.find((tab) => { const current = appTabDestinationKey(tab.href); return current !== null && current === destinationKey(restoredHref); }) ?? null; } // Several surfaces keep their selection out of the address and publish // the href that reconstructs them instead (/pull-requests hides `?pr=`). // A reload of such a page lands on a URL that // is a prefix of the remembered destination: when the remembered tab // opens the same page and the URL brings no selection of its own, the // load restores that tab — it is not a deep link free to claim another // row and grind its location under the load URL. const reloaded = Boolean(restoredTab && restoredHref && reopensRemembered(normalized, restoredHref)); // The address bar is the freshest intent: when it carries everything the // memory has (same selection, maybe an anchor more), it wins — an anchor // the memory lacks must survive the reload. When the memory is the // richer one, it reconstructs the selection the URL keeps out of the // address (?pr=…). const loadWins = Boolean(restoredHref && reopensRemembered(restoredHref, normalized)); const remembered = restoredTab && (!explicit || (restoredHref !== null && destinationKey(restoredHref) === destinationKey(normalized)) || reloaded || loadWins) ? restoredTab : null; const exact = explicit ? this.snapshot.tabs.find((tab) => destinationKey(tab.href) === destinationKey(normalized)) ?? null : null; // A refresh can precede sealing the active-window snapshot. Surfaces // publish richer selections than their bare URL, so a unique server row // is enough to recover identity without creating another tab. Explicit // selections, ambiguous rows and account-switch entry keep their rules. const richer = explicit && !keepCurrentRoute && !normalized.includes("?") && !normalized.includes("#") ? this.snapshot.tabs.filter((tab) => reopensRemembered(normalized, tab.href)) : []; const recovered = !remembered && !exact && richer.length === 1 ? richer[0] : null; const matched = exact ?? recovered; if (explicit && !remembered && !matched && (keepCurrentRoute || normalized !== "/home")) { // No row displays the load address: give it its own row. The document // is already there, so nothing navigates — the row joins the account // like a newly created tab, and every other row keeps its location. const position = this.snapshot.tabs.reduce((maximum, row) => Math.max(maximum, row.position), -1) + 1; const claimed: AppTab = { ...createHomeTab(this.owner, crypto.randomUUID(), position), href: normalized }; this.creating.add(claimed.id); this.locations.set(claimed.id, normalized); this.merge(claimed); void this.persist(() => this.createOnServer(claimed.id)); void this.flushLocation(); this.emit({ activeId: claimed.id }); this.activeHref = normalized; this.observedHref = normalized; this.remember(claimed.id, normalized); return; } const chosen = (remembered ?? matched) ?? this.snapshot.tabs[0]; if (!chosen || this.disposed) return; const destination = explicit ? recovered ? recovered.href : reloaded ? loadWins ? normalized : restoredHref ?? normalized : normalized : restoredTab && restoredTab.id === chosen.id ? restoredHref ?? chosen.href : chosen.href; this.emit({ activeId: chosen.id }); this.activeHref = destination; this.observedHref = destination; this.remember(chosen.id, destination); if (destination !== href && (!explicit || reloaded || recovered)) { this.setTarget(destination); this.navigate(destination); } else if (destination !== chosen.href) { this.locations.set(chosen.id, destination); await this.flushLocation(); } }); } /** Called only for a committed route or view change, never for a refetch. */ observe(href: string, published: string | null) { if (!this.snapshot.activeId || this.disposed) return; const activeId = this.snapshot.activeId; const location = normalizeAppTabLocation(href); const view = published ? normalizeAppTabLocation(published) : null; if (!location) return; if (this.target) { // A consumed selection acknowledges restoration by publishing its target. // If there is no publication, the URL itself is the acknowledgement. // Compared as destination keys: the target may hold full ids where the // address carries short codes. const targetUrl = new URL(appTabDestinationKey(this.target) ?? this.target, "https://minddy.invalid"); const candidate = new URL(appTabDestinationKey(view ?? location) ?? location, "https://minddy.invalid"); const matches = candidate.pathname === targetUrl.pathname && [...targetUrl.searchParams].every(([key, value]) => candidate.searchParams.get(key) === value); const locationPath = (appTabDestinationKey(location) ?? location).split(/[?#]/)[0]; if (!matches || locationPath !== targetUrl.pathname) return; const destination = this.target; this.target = null; clearTimeout(this.targetTimer); // The DESTINATION is the truth here, not the URL or publication on // hand: while a switch is settling, both still describe the OUTGOING // screen (the router commit and the incoming page's own publication // have not landed yet). Adopting them glued the outgoing family // board's `?family=` onto the plain tab that replaced it — and the // poisoned destination then kept that board on screen forever. The // incoming page republishes right after its mount, and that fresher, // tagged view refines the location in the next observation. // Activation already published this destination: only a change renders. const changed = this.observedHref !== destination || this.activeHref !== destination; this.observedHref = destination; this.activeHref = destination; if (changed) this.emit({}); this.remember(activeId, destination); if (this.snapshot.recovering) return; this.locations.set(activeId, destination); const acknowledged = this.active(); if (acknowledged && acknowledged.href !== destination) this.merge(acknowledged); clearTimeout(this.timer); this.timer = setTimeout(() => void this.flushLocation(), 250); return; } const next = view && view.split(/[?#]/)[0] === location.split(/[?#]/)[0] ? view : location; if (next === this.observedHref) return; this.observedHref = next; this.activeHref = next; this.emit({}); this.remember(activeId, next); if (this.snapshot.recovering) return; this.locations.set(activeId, next); const tab = this.active(); if (tab && tab.href !== next) this.merge(tab); clearTimeout(this.timer); this.timer = setTimeout(() => void this.flushLocation(), 250); } private async createOnServer(id: string) { if (!this.creating.has(id)) return; const tab = await this.transport.create(false, id); this.creating.delete(id); this.adopt(tab); } private flushLocation() { clearTimeout(this.timer); const ids = [...this.locations.keys()]; return this.persist(async () => { for (const id of ids) { if (this.closing.has(id) || this.closed.has(id)) continue; const href = this.locations.get(id); if (!href) continue; await this.createOnServer(id); const tab = this.snapshot.tabs.find((row) => row.id === id); if (!tab) continue; // Activation, restoration and a route that briefly drops its published // selection all come back to the href the server row already holds. // Writing it again would only bump the revision and echo to every window. const known = this.confirmed.get(id); if (known?.revision === tab.revision && known.href === href) { if (this.locations.get(id) === href) this.locations.delete(id); continue; } const saved = await this.transport.patch(tab, { href }); if (this.locations.get(id) === href) this.locations.delete(id); this.adopt(saved); } }); } /** Pending location writes leave WITH the page (a refresh hides it first): the debounced flush would never fire, and the next load would restore a stale destination — or treat the fresh URL as an unmatched deep link and grind the first tab under it. The queue keeps the writes ordered; the fetch itself races the unload, which makes this best effort. */ onPageHide = () => { clearTimeout(this.timer); void this.flushLocation(); }; private select(tab: AppTab) { if (this.disposed) return; const previous = this.snapshot.activeId; if (this.snapshot.recovering && previous && previous !== tab.id) { this.forget(previous); this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== previous) }); } this.setTarget(tab.href); this.activeHref = tab.href; this.observedHref = tab.href; this.emit({ activeId: tab.id, recovering: false }); this.remember(tab.id, tab.href); this.navigate(tab.href); } private setTarget(href: string) { this.target = href; clearTimeout(this.targetTimer); this.targetTimer = setTimeout(() => { this.target = null; this.emit({ error: "destination_unavailable" }); }, 15_000); } activate = (id: string) => this.run(async () => { if (id === this.snapshot.activeId) return; if (!(await this.saveEditors())) return; if (!this.snapshot.recovering) void this.flushLocation(); const tab = this.snapshot.tabs.find((row) => row.id === id); if (tab) this.select(tab); }, true); /** Called only when opening a destination, never by route observation. */ reuseDestination = (href: string): boolean => { if (this.disposed || !this.snapshot.activeId) return false; const matches = (tab: AppTab) => sameAppTabDestination(href, tab.href); const active = this.active(); const tab = (active && matches(active) ? active : null) ?? this.snapshot.tabs.find(matches); if (!tab) return false; void this.activate(tab.id); return true; }; /** Prepared same-tab navigation still saves editors and lets route observation persist the location. */ navigateInCurrentTab = (href: string): boolean => { if (this.disposed || !this.snapshot.activeId || this.snapshot.recovering || !normalizeAppTabLocation(href)) return false; void this.run(async () => { if (!(await this.saveEditors())) return; void this.flushLocation(); this.setTarget(href); this.activeHref = href; this.observedHref = null; this.emit({}); this.navigate(href); }, true); return true; }; create = (href: string = "/home") => { if (this.disposed) return Promise.resolve(); const destination = normalizeAppTabLocation(href) ?? "/home"; const position = this.snapshot.tabs.reduce((maximum, row) => Math.max(maximum, row.position), -1) + 1; const tab = { ...createHomeTab(this.owner, crypto.randomUUID(), position), href: destination }; this.creating.add(tab.id); if (destination !== "/home") this.locations.set(tab.id, destination); this.merge(tab); void this.persist(() => this.createOnServer(tab.id)); if (destination !== "/home") void this.flushLocation(); return this.run(async () => { if (!(await this.saveEditors())) return; if (!this.snapshot.recovering) void this.flushLocation(); this.select(tab); }, true); }; goHome = () => this.run(async () => { if (!(await this.saveEditors())) return; const tab = this.active(); if (!tab) return; if (this.snapshot.recovering) return; this.locations.set(tab.id, "/home"); void this.flushLocation(); this.select({ ...tab, href: "/home" }); }, true); update = (id: string, patch: AppTabPatch) => { this.emit({ error: null }); return this.persist(async () => { await this.createOnServer(id); const tab = this.snapshot.tabs.find((row) => row.id === id); if (tab) this.adopt(await this.transport.patch(tab, patch)); }); }; move = (id: string, beforeId: string | null) => this.run(async () => { const previous = this.snapshot.tabs; const tab = previous.find((row) => row.id === id); if (!tab || id === beforeId || (beforeId && !previous.some((row) => row.id === beforeId && row.pinned === tab.pinned))) return; const move = { id, beforeId }; this.orderBase ??= new Map(previous.map((row) => [row.id, row.position])); this.order.push(move); this.emit({ tabs: [...previous] }); void this.persist(async () => { try { await this.createOnServer(id); if (beforeId) await this.createOnServer(beforeId); const current = this.snapshot.tabs.find((row) => row.id === id); if (!current) return; const rows = await this.transport.move(current, beforeId); this.order = this.order.filter((item) => item !== move); this.receive(rows); } catch (error) { this.order = this.order.filter((item) => item !== move); const positions = this.orderBase ?? new Map(previous.map((row) => [row.id, row.position])); this.emit({ tabs: this.snapshot.tabs.map((row) => positions.has(row.id) ? { ...row, position: positions.get(row.id)! } : row) }); throw error; } finally { this.order = this.order.filter((item) => item !== move); if (!this.order.length) this.orderBase = null; } }); }); close = (id: string) => this.run(async () => { if (this.snapshot.tabs.length <= 1) return; if (id === this.snapshot.activeId) { if (!(await this.saveEditors())) return; } const tab = this.snapshot.tabs.find((row) => row.id === id); if (!tab) return; const nextId = selectTabAfterClose(this.snapshot.tabs, id, this.snapshot.activeId ?? id); this.closing.set(id, tab); this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== id) }); if (id === this.snapshot.activeId) { const next = this.snapshot.tabs.find((row) => row.id === nextId); if (next) this.select(next); } void this.persist(async () => { try { await this.createOnServer(id); await this.transport.close(this.closing.get(id) ?? tab); this.closing.delete(id); this.locations.delete(id); this.forget(id); } catch (error) { if (error instanceof AppTabRequestError && error.code === "not_found") { this.closing.delete(id); this.locations.delete(id); this.forget(id); return; } const restored = error instanceof AppTabRequestError && error.tab ? error.tab : this.closing.get(id) ?? tab; this.closing.delete(id); this.merge(restored); throw error; } }); }); recoverRemoteClose = () => this.run(async () => { if (!this.snapshot.recovering || !(await this.saveEditors())) return; const oldId = this.snapshot.activeId; let next = this.snapshot.tabs.find((row) => row.id !== oldId); if (!next) { next = await this.transport.create(true, crypto.randomUUID()); this.adopt(next); } if (oldId) this.forget(oldId); this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== oldId) }); this.select(next); }); retry = () => { if (!this.snapshot.activeId && this.startup) return this.initialize(this.startup.href, this.startup.restored, this.startup.keepCurrentRoute); return this.snapshot.recovering ? this.recoverRemoteClose() : this.run(async () => { await this.persist(async () => { for (const id of this.creating) await this.createOnServer(id); }); await this.flushLocation(); }); }; dispose() { this.disposed = true; this.onDispose(); clearTimeout(this.timer); clearTimeout(this.targetTimer); this.guards.clear(); this.listeners.clear(); this.localState.clear(); } }