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:
parent
06a58f1bba
commit
c2d7e8b238
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue