diff --git a/server/shell.ts b/server/shell.ts index 47b1d81..476315b 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -926,7 +926,13 @@ export function renderShell(opts: ShellOptions): string { const sp = document.querySelector('rstack-space-settings'); if (sp) sp.setAttribute('module-id', moduleId); if (tabCache) { + const switchId = moduleId; // capture for staleness check tabCache.switchTo(moduleId).then(ok => { + // If user already clicked a different tab, don't navigate for this one + if (currentModuleId !== switchId) { + console.log('[shell] switchTo result:', ok, 'for', switchId, '(stale, user switched to', currentModuleId + ')'); + return; + } console.log('[shell] switchTo result:', ok, 'for', moduleId); if (ok) { tabBar.setAttribute('active', layerId); @@ -936,6 +942,7 @@ export function renderShell(opts: ShellOptions): string { window.location.href = url; } }).catch((err) => { + if (currentModuleId !== switchId) return; // stale console.error('[shell] switchTo error:', err); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); }); diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 695cca9..1ac9a2a 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -17,6 +17,7 @@ export class TabCache { private currentModuleId: string; private spaceSlug: string; private panes = new Map(); + private fetchController: AbortController | null = null; constructor(spaceSlug: string, moduleId: string) { this.spaceSlug = spaceSlug; @@ -100,6 +101,12 @@ export class TabCache { /** Switch to a module tab within the current space. Returns true if handled client-side. */ async switchTo(moduleId: string): Promise { + // Abort any in-flight fetch from a previous switchTo call + if (this.fetchController) { + this.fetchController.abort(); + this.fetchController = null; + } + const key = this.paneKey(this.spaceSlug, moduleId); if (moduleId === this.currentModuleId && this.panes.has(key)) { console.log("[TabCache] switchTo", moduleId, "→ already current + cached"); @@ -199,18 +206,25 @@ export class TabCache { this.hideAllPanes(); app.appendChild(loadingPane); + // Create an abort controller that also times out after 15s + const controller = new AbortController(); + this.fetchController = controller; + const timeoutId = setTimeout(() => controller.abort(), 15_000); + try { const resp = await fetch(fetchUrl, { headers: { "Accept": "text/html" }, - signal: AbortSignal.timeout(10_000), + signal: controller.signal, }); if (!resp.ok) { + clearTimeout(timeoutId); console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl); loadingPane.remove(); return false; } const html = await resp.text(); + clearTimeout(timeoutId); console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId); const content = this.extractContent(html); if (!content) { @@ -247,8 +261,16 @@ export class TabCache { console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId); return true; } catch (err) { - console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err); + clearTimeout(timeoutId); loadingPane.remove(); + // If aborted because the user switched to a different tab, return + // "true" so the shell doesn't trigger a fallback full-page navigation + // for a tab the user no longer wants. + if (controller.signal.aborted && this.fetchController !== controller) { + console.log("[TabCache] fetchAndInject: aborted (superseded) for", moduleId); + return true; + } + console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err); return false; } }