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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:23:45 -07:00
parent 50edf06900
commit 433833da0c
2 changed files with 15 additions and 26 deletions

View File

@ -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));
}

View File

@ -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();