From 433833da0ce4979ffcde7160ef2380482923df70 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 16:23:45 -0700 Subject: [PATCH] fix(shell): prevent remote sync from wiping all tabs and showing dashboard reconcileRemoteLayers() could wipe all local tabs when Automerge sync or BroadcastChannel delivered empty layer data (CRDT initial state, sync race). This caused clicking tabs to show an infinite-loading dashboard. Now: empty remote layers are rejected when an active module exists, and the current module always stays in the tab list. Also adds 10s fetch timeout to TabCache.fetchAndInject() to prevent infinite loading spinners on slow/failed module fetches. Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 40 ++++++++++++++-------------------------- shared/tab-cache.ts | 1 + 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/server/shell.ts b/server/shell.ts index 9201888..4c38a7c 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -844,6 +844,15 @@ export function renderShell(opts: ShellOptions): string { // Reconcile remote layer changes (shared by BroadcastChannel + Automerge) function reconcileRemoteLayers(remoteLayers) { + // Guard: never let remote sync wipe all tabs when we have an active module. + // Empty remote layers indicate a CRDT initial state or sync race, not + // an intentional "close everything" action. + if (remoteLayers.length === 0 && currentModuleId) { + // Keep local layers intact — remote has nothing useful + tabBar.setLayers(layers); + return; + } + const prev = new Set(layers.map(l => l.moduleId)); const next = new Set(remoteLayers.map(l => l.moduleId)); @@ -854,35 +863,14 @@ export function renderShell(opts: ShellOptions): string { layers = deduplicateLayers(remoteLayers); + // Always ensure the current module stays in the tab list if (currentModuleId && !layers.find(l => l.moduleId === currentModuleId)) { - // Active tab was closed remotely — switch to nearest - if (layers.length > 0) { - const nearest = layers[layers.length - 1]; - currentModuleId = nearest.moduleId; - tabBar.setLayers(layers); - tabBar.setAttribute('active', 'layer-' + currentModuleId); - if (tabCache) { - tabCache.switchTo(currentModuleId).then(ok => { - if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, currentModuleId); - }); - } - } else { - // No tabs left — show dashboard - const dashboard = document.querySelector('rstack-user-dashboard'); - if (dashboard && dashboard.setOpenTabs) dashboard.setOpenTabs([...layers]); - tabBar.setLayers([]); - tabBar.setAttribute('active', ''); - currentModuleId = ''; - if (tabCache) tabCache.hideAllPanes(); - if (dashboard) { dashboard.style.display = ''; if (dashboard.refresh) dashboard.refresh(); } - const app = document.getElementById('app'); - if (app) app.classList.remove('canvas-layout'); - history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug); - } - } else { - tabBar.setLayers(layers); + layers.push(makeLayer(currentModuleId, layers.length)); } + tabBar.setLayers(layers); + tabBar.setAttribute('active', 'layer-' + currentModuleId); + localStorage.setItem(TABS_KEY, JSON.stringify(layers)); } diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 1c89977..71353e0 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -194,6 +194,7 @@ export class TabCache { try { const resp = await fetch(fetchUrl, { headers: { "Accept": "text/html" }, + signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { loadingPane.remove();