diff --git a/lib/community-sync.ts b/lib/community-sync.ts index ac11b937..1543f45d 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -880,6 +880,7 @@ export class CommunitySync extends EventTarget { */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; + const validIds = new Set(); for (const [id, shapeData] of Object.entries(shapes)) { const d = shapeData as Record; @@ -890,6 +891,7 @@ export class CommunitySync extends EventTarget { && (fb as Record)[this.#localDID]) { continue; } + validIds.add(id); this.#applyShapeToDOM(shapeData); // If forgotten by others (but not this user), emit state-changed for fade visual if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) { @@ -899,6 +901,13 @@ export class CommunitySync extends EventTarget { } } + // Prune stale DOM shapes that are deleted, forgotten, or no longer in the doc + for (const id of this.#shapes.keys()) { + if (!validIds.has(id)) { + this.#removeShapeFromDOM(id); + } + } + // Notify event bus if there are any events to process if (this.#doc.eventLog && this.#doc.eventLog.length > 0) { this.dispatchEvent(new CustomEvent("eventlog-changed")); diff --git a/server/shell.ts b/server/shell.ts index b84310d0..0cfd0ace 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -1069,11 +1069,10 @@ 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) { + function reconcileRemoteLayers(remoteLayers, fromUserAction) { + // Guard: never let remote sync wipe all tabs when we have an active module, + // UNLESS this came from a deliberate user action (e.g. close-all via BroadcastChannel). + if (remoteLayers.length === 0 && currentModuleId && !fromUserAction) { // Keep local layers intact — remote has nothing useful tabBar.setLayers(layers); return; @@ -1105,7 +1104,7 @@ export function renderShell(opts: ShellOptions): string { if (e.data?.type !== 'tabs-sync') return; const remoteLayers = e.data.layers || []; for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid); - reconcileRemoteLayers(remoteLayers); + reconcileRemoteLayers(remoteLayers, true); }; } @@ -1126,8 +1125,9 @@ export function renderShell(opts: ShellOptions): string { .then(r => r.ok ? r.json() : null) .then(data => { if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { - // Server has nothing — push localStorage tabs up - saveTabs(); + // Server has no saved tabs. Only push if user has meaningful local tabs + // (not just the auto-added current module) + if (layers.length > 1) saveTabs(); return; } // Server-authoritative: adopt server tabs exactly. @@ -1522,23 +1522,22 @@ export function renderShell(opts: ShellOptions): string { const localActiveId = 'layer-' + currentModuleId; - // Merge: Automerge layers win if they exist, otherwise seed from localStorage + // User's saved tabs are authoritative — sync Automerge to match. const remoteLayers = sync.getLayers(); - if (remoteLayers.length > 0) { - // Ensure current module is also in the Automerge set - if (!remoteLayers.find(l => l.moduleId === currentModuleId)) { - const newLayer = makeLayer(currentModuleId, remoteLayers.length); - sync.addLayer(newLayer); + + // Remove Automerge-only layers that the user doesn't have open + for (const rl of remoteLayers) { + if (!layers.find(l => l.moduleId === rl.moduleId)) { + sync.removeLayer(rl.id); } - layers = deduplicateLayers(sync.getLayers()); - tabBar.setLayers(layers); - tabBar.setFlows(sync.getFlows()); - } else { - // First connection: push all localStorage tabs into Automerge - for (const l of layers) { + } + // Add user's tabs that aren't in Automerge yet + for (const l of layers) { + if (!remoteLayers.find(rl => rl.moduleId === l.moduleId)) { sync.addLayer(l); } } + tabBar.setFlows(sync.getFlows()); // Active tab stays local — always matches the URL tabBar.setAttribute('active', localActiveId); @@ -1575,7 +1574,7 @@ export function renderShell(opts: ShellOptions): string { // Never touch the active tab: it's managed locally by TabCache // and the tab-bar component via layer-switch events. sync.addEventListener('change', () => { - reconcileRemoteLayers(sync.getLayers()); + reconcileRemoteLayers(sync.getLayers(), false); tabBar.setFlows(sync.getFlows()); const viewMode = sync.doc.layerViewMode; if (viewMode) tabBar.setAttribute('view-mode', viewMode);