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:
parent
ad22ed7482
commit
be81618b70
|
|
@ -540,23 +540,29 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
// Helper: save current tab list to localStorage + server
|
// Helper: save current tab list to localStorage + server
|
||||||
let _tabSaveTimer = null;
|
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));
|
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
||||||
// Debounced server save for authenticated users
|
// Debounced server save for authenticated users
|
||||||
clearTimeout(_tabSaveTimer);
|
clearTimeout(_tabSaveTimer);
|
||||||
_tabSaveTimer = setTimeout(() => {
|
const doSave = () => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('encryptid_session');
|
const raw = localStorage.getItem('encryptid_session');
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const session = JSON.parse(raw);
|
const session = JSON.parse(raw);
|
||||||
if (!session?.accessToken) return;
|
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',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
|
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(() => {});
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}, 500);
|
};
|
||||||
|
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch tabs from server for authenticated users (merge with localStorage)
|
// Fetch tabs from server for authenticated users (merge with localStorage)
|
||||||
|
|
@ -577,11 +583,12 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Merge: union of moduleIds, server order wins for shared tabs
|
// 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 serverMap = new Map(data.tabs.map(t => [t.moduleId, t]));
|
||||||
const localMap = new Map(layers.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) {
|
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; });
|
merged.forEach((l, i) => { l.order = i; });
|
||||||
layers = merged;
|
layers = merged;
|
||||||
|
|
@ -659,6 +666,8 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
const wasActive = layerId === tabBar.getAttribute('active');
|
const wasActive = layerId === tabBar.getAttribute('active');
|
||||||
tabBar.removeLayer(layerId);
|
tabBar.removeLayer(layerId);
|
||||||
layers = layers.filter(l => l.id !== layerId);
|
layers = layers.filter(l => l.id !== layerId);
|
||||||
|
// Track closed tab so server merge doesn't resurrect it
|
||||||
|
_closedModuleIds.add(closedModuleId);
|
||||||
saveTabs();
|
saveTabs();
|
||||||
|
|
||||||
// If this was a space layer with a persisted SpaceRef, clean it up
|
// 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;
|
currentModuleId = nextModuleId;
|
||||||
if (tabCache) {
|
if (tabCache) {
|
||||||
tabCache.switchTo(nextModuleId).then(ok => {
|
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 {
|
} else {
|
||||||
|
saveTabs(true); // flush to server before navigation
|
||||||
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
|
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue