fix(shell): prevent infinite loop when rapidly switching tabs

Abort previous in-flight fetch when switchTo() is called again, and
guard the shell's fallback navigation against stale callbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 11:44:38 -07:00
parent 06a58f1bba
commit c2d7e8b238
2 changed files with 31 additions and 2 deletions

View File

@ -926,7 +926,13 @@ export function renderShell(opts: ShellOptions): string {
const sp = document.querySelector('rstack-space-settings'); const sp = document.querySelector('rstack-space-settings');
if (sp) sp.setAttribute('module-id', moduleId); if (sp) sp.setAttribute('module-id', moduleId);
if (tabCache) { if (tabCache) {
const switchId = moduleId; // capture for staleness check
tabCache.switchTo(moduleId).then(ok => { 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); console.log('[shell] switchTo result:', ok, 'for', moduleId);
if (ok) { if (ok) {
tabBar.setAttribute('active', layerId); tabBar.setAttribute('active', layerId);
@ -936,6 +942,7 @@ export function renderShell(opts: ShellOptions): string {
window.location.href = url; window.location.href = url;
} }
}).catch((err) => { }).catch((err) => {
if (currentModuleId !== switchId) return; // stale
console.error('[shell] switchTo error:', err); console.error('[shell] switchTo error:', err);
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
}); });

View File

@ -17,6 +17,7 @@ export class TabCache {
private currentModuleId: string; private currentModuleId: string;
private spaceSlug: string; private spaceSlug: string;
private panes = new Map<string, HTMLElement>(); private panes = new Map<string, HTMLElement>();
private fetchController: AbortController | null = null;
constructor(spaceSlug: string, moduleId: string) { constructor(spaceSlug: string, moduleId: string) {
this.spaceSlug = spaceSlug; 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. */ /** Switch to a module tab within the current space. Returns true if handled client-side. */
async switchTo(moduleId: string): Promise<boolean> { async switchTo(moduleId: string): Promise<boolean> {
// 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); const key = this.paneKey(this.spaceSlug, moduleId);
if (moduleId === this.currentModuleId && this.panes.has(key)) { if (moduleId === this.currentModuleId && this.panes.has(key)) {
console.log("[TabCache] switchTo", moduleId, "→ already current + cached"); console.log("[TabCache] switchTo", moduleId, "→ already current + cached");
@ -199,18 +206,25 @@ export class TabCache {
this.hideAllPanes(); this.hideAllPanes();
app.appendChild(loadingPane); 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 { try {
const resp = await fetch(fetchUrl, { const resp = await fetch(fetchUrl, {
headers: { "Accept": "text/html" }, headers: { "Accept": "text/html" },
signal: AbortSignal.timeout(10_000), signal: controller.signal,
}); });
if (!resp.ok) { if (!resp.ok) {
clearTimeout(timeoutId);
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl); console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
loadingPane.remove(); loadingPane.remove();
return false; return false;
} }
const html = await resp.text(); const html = await resp.text();
clearTimeout(timeoutId);
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId); console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
const content = this.extractContent(html); const content = this.extractContent(html);
if (!content) { if (!content) {
@ -247,8 +261,16 @@ export class TabCache {
console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId); console.log("[TabCache] fetchAndInject: SUCCESS for", moduleId);
return true; return true;
} catch (err) { } catch (err) {
console.error("[TabCache] fetchAndInject: CATCH for", moduleId, err); clearTimeout(timeoutId);
loadingPane.remove(); 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; return false;
} }
} }