fix(shell): eliminate phantom tab persistence — validate + dedup on restore

Filter restored tabs against valid moduleIds from moduleList, deduplicate
on every restore path (localStorage, server sync, BroadcastChannel).
Add closed-module tracking to renderExternalAppShell to prevent server
sync from resurrecting tabs closed in the current session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 17:32:15 -04:00
parent cb521cad12
commit 932e550c66
1 changed files with 22 additions and 6 deletions

View File

@ -871,6 +871,9 @@ export function renderShell(opts: ShellOptions): string {
};
}
// Valid module IDs for this space (used to filter out stale/phantom tabs)
const validModuleIds = new Set(moduleList.map(m => m.id));
// ── Restore tabs from localStorage ──
let layers;
try {
@ -879,12 +882,15 @@ export function renderShell(opts: ShellOptions): string {
if (!Array.isArray(layers)) layers = [];
} catch(e) { layers = []; }
// Filter out stale tabs whose moduleId no longer exists
layers = deduplicateLayers(layers.filter(l => validModuleIds.has(l.moduleId)));
// Ensure the current module is in the tab list
if (!layers.find(l => l.moduleId === currentModuleId)) {
layers.push(makeLayer(currentModuleId, layers.length));
}
// Persist immediately (includes the newly-added tab)
// Persist immediately (cleaned list)
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Render all tabs with the current one active
@ -954,7 +960,7 @@ export function renderShell(opts: ShellOptions): string {
if (!next.has(mid) && tabCache) tabCache.removePane(mid);
}
layers = deduplicateLayers(remoteLayers);
layers = deduplicateLayers(remoteLayers.filter(l => validModuleIds.has(l.moduleId)));
// Always ensure the current module stays in the tab list
if (currentModuleId && !layers.find(l => l.moduleId === currentModuleId)) {
@ -1001,7 +1007,8 @@ export function renderShell(opts: ShellOptions): string {
// Server-authoritative: adopt server tabs exactly.
// Only skip tabs the user closed in THIS session (prevents
// close-then-immediate-resurrect before save propagates).
var serverTabs = data.tabs.filter(t => !_closedModuleIds.has(t.moduleId));
var serverTabs = data.tabs.filter(t => validModuleIds.has(t.moduleId) && !_closedModuleIds.has(t.moduleId));
serverTabs = deduplicateLayers(serverTabs);
serverTabs.forEach((l, i) => { l.order = i; });
layers = serverTabs;
// Ensure the currently navigated module is present
@ -1711,6 +1718,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
if (tabBar) {
tabBar.setModules(moduleList);
const validModuleIds = new Set(moduleList.map(m => m.id));
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
@ -1718,13 +1726,20 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
function makeLayer(id, order) {
return { id: 'layer-' + id, moduleId: id, label: getModuleLabel(id), order, color: '', visible: true, createdAt: Date.now() };
}
function deduplicateLayers(list) {
const seen = new Set();
return list.filter(l => { if (seen.has(l.moduleId)) return false; seen.add(l.moduleId); return true; });
}
let layers;
try { const saved = localStorage.getItem(TABS_KEY); layers = saved ? JSON.parse(saved) : []; if (!Array.isArray(layers)) layers = []; } catch(e) { layers = []; }
// Filter out stale/phantom tabs and deduplicate
layers = deduplicateLayers(layers.filter(l => validModuleIds.has(l.moduleId)));
if (!layers.find(l => l.moduleId === currentModuleId)) layers.push(makeLayer(currentModuleId, layers.length));
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
const _closedModuleIds = new Set();
function saveTabs() {
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
try {
@ -1754,7 +1769,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { saveTabs(); return; }
layers = data.tabs;
// Filter stale moduleIds and tabs closed this session
layers = deduplicateLayers(data.tabs.filter(function(t) { return validModuleIds.has(t.moduleId) && !_closedModuleIds.has(t.moduleId); }));
layers.forEach(function(l, i) { l.order = i; });
if (!layers.find(function(l) { return l.moduleId === currentModuleId; })) layers.push(makeLayer(currentModuleId, layers.length));
tabBar.setLayers(layers);
@ -1765,8 +1781,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
})();
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layers-close-all', () => { layers = []; tabBar.setLayers([]); tabBar.setAttribute('active', ''); saveTabs(); var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; window.location.href = dashUrl; });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; const closedMid = layerId.replace('layer-', ''); _closedModuleIds.add(closedMid); tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layers-close-all', () => { layers.forEach(l => _closedModuleIds.add(l.moduleId)); layers = []; tabBar.setLayers([]); tabBar.setAttribute('active', ''); saveTabs(); var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; window.location.href = dashUrl; });
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
}
</script>