diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 1543f45d..a040c936 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -157,6 +157,8 @@ export class CommunitySync extends EventTarget { #disconnectedIntentionally = false; #communitySlug: string; #shapes: Map = new Map(); + #shapeListeners: Map = new Map(); + #changeCount = 0; #pendingChanges: boolean = false; #reconnectAttempts = 0; #maxReconnectAttempts = 5; @@ -532,15 +534,25 @@ export class CommunitySync extends EventTarget { registerShape(shape: FolkShape): void { this.#shapes.set(shape.id, shape); - // Listen for transform events - shape.addEventListener("folk-transform", ((e: CustomEvent) => { - this.#handleShapeChange(shape); - }) as EventListener); + // Remove stale listeners if shape is re-registered + const old = this.#shapeListeners.get(shape.id); + if (old) { + shape.removeEventListener("folk-transform", old.transform); + shape.removeEventListener("content-change", old.content); + } - // Listen for content changes (for markdown shapes) - shape.addEventListener("content-change", ((e: CustomEvent) => { + // Create named listener refs so they can be removed later + const transformListener = (() => { this.#handleShapeChange(shape); - }) as EventListener); + }) as EventListener; + const contentListener = (() => { + this.#handleShapeChange(shape); + }) as EventListener; + + this.#shapeListeners.set(shape.id, { transform: transformListener, content: contentListener }); + + shape.addEventListener("folk-transform", transformListener); + shape.addEventListener("content-change", contentListener); // Add to document if not exists if (!this.#doc.shapes[shape.id]) { @@ -555,6 +567,13 @@ export class CommunitySync extends EventTarget { * Unregister a shape */ unregisterShape(shapeId: string): void { + const shape = this.#shapes.get(shapeId); + const listeners = this.#shapeListeners.get(shapeId); + if (shape && listeners) { + shape.removeEventListener("folk-transform", listeners.transform); + shape.removeEventListener("content-change", listeners.content); + } + this.#shapeListeners.delete(shapeId); this.#shapes.delete(shapeId); } @@ -582,6 +601,12 @@ export class CommunitySync extends EventTarget { doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData)); }); + // Compact Automerge history periodically to prevent unbounded WASM heap growth + this.#changeCount++; + if (this.#changeCount % 500 === 0) { + this.#doc = Automerge.clone(this.#doc); + } + // Record for undo (skip if this is a brand-new shape — registerShape handles that) if (beforeData) { this.#pushUndo(shape.id, beforeData, this.#cloneShapeData(shape.id)); diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index 618c3e39..21c3983d 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -615,7 +615,7 @@ export class FolkPrompt extends FolkShape { const content = this.#promptInput?.value.trim(); if (!content || this.#isStreaming) return; - // Add user message with any pending images + // Add user message with any pending images (cap at 30 to prevent unbounded growth with base64 images) const userMessage: ChatMessage = { id: crypto.randomUUID(), role: "user", @@ -624,6 +624,7 @@ export class FolkPrompt extends FolkShape { timestamp: new Date(), }; this.#messages.push(userMessage); + if (this.#messages.length > 30) this.#messages = this.#messages.slice(-30); // Clear input and pending images if (this.#promptInput) this.#promptInput.value = ""; diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index 71d70ec6..b5bfe20a 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -50,6 +50,7 @@ export class RStackMi extends HTMLElement { #voiceDictation: SpeechDictation | null = null; #voiceAccumulated = ""; #voiceSilenceTimer: ReturnType | null = null; + #outsideClickHandler: ((e: PointerEvent) => void) | null = null; constructor() { super(); @@ -67,6 +68,10 @@ export class RStackMi extends HTMLElement { disconnectedCallback() { document.removeEventListener("keydown", this.#keyHandler); + if (this.#outsideClickHandler) { + document.removeEventListener("pointerdown", this.#outsideClickHandler as EventListener); + this.#outsideClickHandler = null; + } if (this.#placeholderTimer) clearInterval(this.#placeholderTimer); if (this.#voiceMode) this.#deactivateVoiceMode(); } @@ -261,14 +266,18 @@ export class RStackMi extends HTMLElement { }); // Close panel on outside click — use composedPath to pierce Shadow DOM - document.addEventListener("pointerdown", (e) => { + if (this.#outsideClickHandler) { + document.removeEventListener("pointerdown", this.#outsideClickHandler as EventListener); + } + this.#outsideClickHandler = (e: PointerEvent) => { if (this.#voiceMode) return; // Keep panel open during voice conversation const path = e.composedPath(); if (!path.includes(this)) { panel.classList.remove("open"); bar.classList.remove("focused"); } - }); + }; + document.addEventListener("pointerdown", this.#outsideClickHandler as EventListener); // Prevent internal clicks from closing panel.addEventListener("pointerdown", (e) => e.stopPropagation()); @@ -687,8 +696,9 @@ export class RStackMi extends HTMLElement { panel.classList.add("open"); this.#shadow.getElementById("mi-bar")!.classList.add("focused"); - // Add user message + // Add user message (cap at 50 to prevent unbounded growth) this.#messages.push({ role: "user", content: query }); + if (this.#messages.length > 50) this.#messages = this.#messages.slice(-50); this.#renderMessages(messagesEl); // Add placeholder for assistant diff --git a/website/canvas.html b/website/canvas.html index 88f29120..01a14203 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3840,6 +3840,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest // Also remove wb-svg elements from the overlay const wbEl = wbOverlay?.querySelector(`[data-wb-id="${shapeId}"]`); if (wbEl) wbEl.remove(); + // Clean up stale position tracking for deleted shapes + shapeLastPos.delete(shapeId); // Hide schedule icon and reminder widget if deleted shape was selected selectedShapeIds.delete(shapeId); updateSelectionVisuals(); @@ -7472,7 +7474,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest }); // Keep-alive ping to prevent WebSocket idle timeout - setInterval(() => { + const keepAliveInterval = setInterval(() => { try { sync.ping(); } catch (e) {