fix: thread builder save crash — await Automerge doc open before write

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 16:38:50 -08:00
parent 0318f0a7e1
commit 0abfce2991
3 changed files with 28 additions and 12 deletions

View File

@ -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() }];

View File

@ -30,6 +30,7 @@ export class FolkThreadBuilder extends HTMLElement {
private _tweetImages: Record<string, string> = {};
private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
private _offlineUnsub: (() => void) | null = null;
private _offlineReady: Promise<void> | 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];

View File

@ -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') {