lib/app-tabs-session.ts
Rama main · réplica de lectura del repositorio original.
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<AppTab>;
patch: (tab: AppTab, patch: AppTabPatch) => Promise<AppTab>;
close: (tab: AppTab) => Promise<void>;
move: (tab: AppTab, beforeId: string | null) => Promise<AppTab[]>;
}
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<boolean>>();
private queue: Promise<unknown> = Promise.resolve();
private writes: Promise<unknown> = Promise.resolve();
private creating = new Set<string>();
private closing = new Map<string, AppTab>();
private locations = new Map<string, string>();
/** The newest server row seen per tab, so an unchanged location skips its write. */
private confirmed = new Map<string, { href: string; revision: number }>();
private order: { id: string; beforeId: string | null }[] = [];
private orderBase: Map<string, number> | null = null;
private closed = new Set<string>();
private disposed = false;
private observedHref: string | null = null;
private activeHref: string | null = null;
private target: string | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private targetTimer: ReturnType<typeof setTimeout> | undefined;
private localState = new Map<string, unknown>();
private startup: { href: string; restored?: { id: string; href: string }; keepCurrentRoute: boolean } | null = null;
getLocalState<T>(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<AppTabsSnapshot>) {
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<boolean>) => {
this.guards.add(guard);
return () => { this.guards.delete(guard); };
};
private async saveEditors(): Promise<boolean> {
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<void>): Promise<void> {
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<void>, departureOnly = false): Promise<void> {
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<void> {
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(); }
}