fix(tabs): prevent closed tabs from reopening via server sync race

Two race conditions caused closed tabs to resurrect:
1. syncTabsFromServer() fetch completing after a local close, merging
   the stale server response back in
2. Debounced PUT killed by page navigation when closing the active tab,
   so the server never learned about the close

Fix: track closed moduleIds per session to skip during merge, and flush
server PUT with keepalive:true before navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 20:30:11 -07:00
parent ad22ed7482
commit be81618b70
1 changed files with 21 additions and 8 deletions

View File

@ -540,23 +540,29 @@ export function renderShell(opts: ShellOptions): string {
// Helper: save current tab list to localStorage + server
let _tabSaveTimer = null;
function saveTabs() {
// Track tabs closed this session so server merge doesn't resurrect them
const _closedModuleIds = new Set();
function saveTabs(immediate) {
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Debounced server save for authenticated users
clearTimeout(_tabSaveTimer);
_tabSaveTimer = setTimeout(() => {
const doSave = () => {
try {
const raw = localStorage.getItem('encryptid_session');
if (!raw) return;
const session = JSON.parse(raw);
if (!session?.accessToken) return;
fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), {
const url = '/api/user/tabs/' + encodeURIComponent(spaceSlug);
const body = JSON.stringify({ tabs: layers });
fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
body: JSON.stringify({ tabs: layers }),
body: body,
keepalive: immediate, // survive page navigation when flushing immediately
}).catch(() => {});
} catch(e) {}
}, 500);
};
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
}
// Fetch tabs from server for authenticated users (merge with localStorage)
@ -577,11 +583,12 @@ export function renderShell(opts: ShellOptions): string {
return;
}
// Merge: union of moduleIds, server order wins for shared tabs
// Skip tabs that were closed this session (prevents resurrection)
const serverMap = new Map(data.tabs.map(t => [t.moduleId, t]));
const localMap = new Map(layers.map(t => [t.moduleId, t]));
const merged = [...data.tabs];
const merged = data.tabs.filter(t => !_closedModuleIds.has(t.moduleId));
for (const [mid, lt] of localMap) {
if (!serverMap.has(mid)) merged.push(lt);
if (!serverMap.has(mid) && !_closedModuleIds.has(mid)) merged.push(lt);
}
merged.forEach((l, i) => { l.order = i; });
layers = merged;
@ -659,6 +666,8 @@ export function renderShell(opts: ShellOptions): string {
const wasActive = layerId === tabBar.getAttribute('active');
tabBar.removeLayer(layerId);
layers = layers.filter(l => l.id !== layerId);
// Track closed tab so server merge doesn't resurrect it
_closedModuleIds.add(closedModuleId);
saveTabs();
// If this was a space layer with a persisted SpaceRef, clean it up
@ -696,9 +705,13 @@ export function renderShell(opts: ShellOptions): string {
currentModuleId = nextModuleId;
if (tabCache) {
tabCache.switchTo(nextModuleId).then(ok => {
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
if (!ok) {
saveTabs(true); // flush to server before navigation
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
}
});
} else {
saveTabs(true); // flush to server before navigation
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
}
}