fix(sync): prune stale DOM shapes + make tab reconciliation user-authoritative
community-sync: remove DOM shapes that are deleted/forgotten from doc. shell: treat user's saved tabs as authoritative over Automerge, pass fromUserAction flag to reconcileRemoteLayers to allow intentional close-all. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1e5f04398b
commit
6e36b87a45
|
|
@ -880,6 +880,7 @@ export class CommunitySync extends EventTarget {
|
||||||
*/
|
*/
|
||||||
#applyDocToDOM(): void {
|
#applyDocToDOM(): void {
|
||||||
const shapes = this.#doc.shapes || {};
|
const shapes = this.#doc.shapes || {};
|
||||||
|
const validIds = new Set<string>();
|
||||||
|
|
||||||
for (const [id, shapeData] of Object.entries(shapes)) {
|
for (const [id, shapeData] of Object.entries(shapes)) {
|
||||||
const d = shapeData as Record<string, unknown>;
|
const d = shapeData as Record<string, unknown>;
|
||||||
|
|
@ -890,6 +891,7 @@ export class CommunitySync extends EventTarget {
|
||||||
&& (fb as Record<string, number>)[this.#localDID]) {
|
&& (fb as Record<string, number>)[this.#localDID]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
validIds.add(id);
|
||||||
this.#applyShapeToDOM(shapeData);
|
this.#applyShapeToDOM(shapeData);
|
||||||
// If forgotten by others (but not this user), emit state-changed for fade visual
|
// If forgotten by others (but not this user), emit state-changed for fade visual
|
||||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
|
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
|
// Notify event bus if there are any events to process
|
||||||
if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
|
if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
|
||||||
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
||||||
|
|
|
||||||
|
|
@ -1069,11 +1069,10 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
||||||
function reconcileRemoteLayers(remoteLayers) {
|
function reconcileRemoteLayers(remoteLayers, fromUserAction) {
|
||||||
// Guard: never let remote sync wipe all tabs when we have an active module.
|
// 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
|
// UNLESS this came from a deliberate user action (e.g. close-all via BroadcastChannel).
|
||||||
// an intentional "close everything" action.
|
if (remoteLayers.length === 0 && currentModuleId && !fromUserAction) {
|
||||||
if (remoteLayers.length === 0 && currentModuleId) {
|
|
||||||
// Keep local layers intact — remote has nothing useful
|
// Keep local layers intact — remote has nothing useful
|
||||||
tabBar.setLayers(layers);
|
tabBar.setLayers(layers);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1105,7 +1104,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
if (e.data?.type !== 'tabs-sync') return;
|
if (e.data?.type !== 'tabs-sync') return;
|
||||||
const remoteLayers = e.data.layers || [];
|
const remoteLayers = e.data.layers || [];
|
||||||
for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid);
|
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(r => r.ok ? r.json() : null)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
||||||
// Server has nothing — push localStorage tabs up
|
// Server has no saved tabs. Only push if user has meaningful local tabs
|
||||||
saveTabs();
|
// (not just the auto-added current module)
|
||||||
|
if (layers.length > 1) saveTabs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Server-authoritative: adopt server tabs exactly.
|
// Server-authoritative: adopt server tabs exactly.
|
||||||
|
|
@ -1522,23 +1522,22 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
const localActiveId = 'layer-' + currentModuleId;
|
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();
|
const remoteLayers = sync.getLayers();
|
||||||
if (remoteLayers.length > 0) {
|
|
||||||
// Ensure current module is also in the Automerge set
|
// Remove Automerge-only layers that the user doesn't have open
|
||||||
if (!remoteLayers.find(l => l.moduleId === currentModuleId)) {
|
for (const rl of remoteLayers) {
|
||||||
const newLayer = makeLayer(currentModuleId, remoteLayers.length);
|
if (!layers.find(l => l.moduleId === rl.moduleId)) {
|
||||||
sync.addLayer(newLayer);
|
sync.removeLayer(rl.id);
|
||||||
}
|
}
|
||||||
layers = deduplicateLayers(sync.getLayers());
|
}
|
||||||
tabBar.setLayers(layers);
|
// Add user's tabs that aren't in Automerge yet
|
||||||
tabBar.setFlows(sync.getFlows());
|
for (const l of layers) {
|
||||||
} else {
|
if (!remoteLayers.find(rl => rl.moduleId === l.moduleId)) {
|
||||||
// First connection: push all localStorage tabs into Automerge
|
|
||||||
for (const l of layers) {
|
|
||||||
sync.addLayer(l);
|
sync.addLayer(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tabBar.setFlows(sync.getFlows());
|
||||||
|
|
||||||
// Active tab stays local — always matches the URL
|
// Active tab stays local — always matches the URL
|
||||||
tabBar.setAttribute('active', localActiveId);
|
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
|
// Never touch the active tab: it's managed locally by TabCache
|
||||||
// and the tab-bar component via layer-switch events.
|
// and the tab-bar component via layer-switch events.
|
||||||
sync.addEventListener('change', () => {
|
sync.addEventListener('change', () => {
|
||||||
reconcileRemoteLayers(sync.getLayers());
|
reconcileRemoteLayers(sync.getLayers(), false);
|
||||||
tabBar.setFlows(sync.getFlows());
|
tabBar.setFlows(sync.getFlows());
|
||||||
const viewMode = sync.doc.layerViewMode;
|
const viewMode = sync.doc.layerViewMode;
|
||||||
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue