From 0abfce2991af650651749c27da8a9ea10abb3900 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Mar 2026 16:38:50 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20thread=20builder=20save=20crash=20?= =?UTF-8?q?=E2=80=94=20await=20Automerge=20doc=20open=20before=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: - attachShadow called unconditionally in connectedCallback, crashing on re-insertion ("Shadow root cannot be created on a host which already hosts a shadow tree") - saveToAutomerge/deleteFromAutomerge called runtime.change() before the async subscribeOffline() had resolved, causing "Document not open" errors Now tracks the subscribe promise and awaits it before any write operation. Also guards shadow root creation in gallery and campaign manager components. Co-Authored-By: Claude Opus 4.6 --- .../components/folk-campaign-manager.ts | 2 +- .../components/folk-thread-builder.ts | 36 +++++++++++++------ .../components/folk-thread-gallery.ts | 2 +- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index 5ef0806..c836e98 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -18,7 +18,7 @@ export class FolkCampaignManager extends HTMLElement { static get observedAttributes() { return ['space']; } connectedCallback() { - this.attachShadow({ mode: 'open' }); + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; // Start with demo campaign this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts index 65a0a55..b8bc318 100644 --- a/modules/rsocials/components/folk-thread-builder.ts +++ b/modules/rsocials/components/folk-thread-builder.ts @@ -30,6 +30,7 @@ export class FolkThreadBuilder extends HTMLElement { private _tweetImages: Record = {}; private _autoSaveTimer: ReturnType | null = null; private _offlineUnsub: (() => void) | null = null; + private _offlineReady: Promise | null = null; private _tweetImageUploadIdx: string | null = null; // SVG icons @@ -44,7 +45,9 @@ export class FolkThreadBuilder extends HTMLElement { static get observedAttributes() { return ['space', 'thread-id', 'mode']; } connectedCallback() { - this.attachShadow({ mode: 'open' }); + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + } this._space = this.getAttribute('space') || 'demo'; this._threadId = this.getAttribute('thread-id') || null; this._mode = (this.getAttribute('mode') as any) || 'new'; @@ -60,7 +63,7 @@ export class FolkThreadBuilder extends HTMLElement { this.render(); if (this._space !== 'demo') { - this.subscribeOffline(); + this._offlineReady = this.subscribeOffline(); } } @@ -112,22 +115,35 @@ export class FolkThreadBuilder extends HTMLElement { return (window as any).__rspaceOfflineRuntime; } - private saveToAutomerge(thread: ThreadData) { + private async saveToAutomerge(thread: ThreadData) { const runtime = this.getRuntime(); if (!runtime?.isInitialized) return; - const docId = socialsDocId(this._space) as DocumentId; - runtime.change(docId, `Save thread ${thread.title || thread.id}`, (d: SocialsDoc) => { - if (!d.threads) d.threads = {} as any; - thread.updatedAt = Date.now(); - d.threads[thread.id] = thread; - }); + // Ensure the document is open before writing + if (this._offlineReady) { + await this._offlineReady; + } + + try { + const docId = socialsDocId(this._space) as DocumentId; + runtime.change(docId, `Save thread ${thread.title || thread.id}`, (d: SocialsDoc) => { + if (!d.threads) d.threads = {} as any; + thread.updatedAt = Date.now(); + d.threads[thread.id] = thread; + }); + } catch (e: any) { + console.error('[thread-builder] Failed to save:', e.message); + } } - private deleteFromAutomerge(id: string) { + private async deleteFromAutomerge(id: string) { const runtime = this.getRuntime(); if (!runtime?.isInitialized) return; + if (this._offlineReady) { + await this._offlineReady; + } + const docId = socialsDocId(this._space) as DocumentId; runtime.change(docId, `Delete thread ${id}`, (d: SocialsDoc) => { if (d.threads?.[id]) delete d.threads[id]; diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index 38c9789..5682f32 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -18,7 +18,7 @@ export class FolkThreadGallery extends HTMLElement { static get observedAttributes() { return ['space']; } connectedCallback() { - this.attachShadow({ mode: 'open' }); + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; this.render(); if (this._space === 'demo') {