fix(canvas): plug memory leaks causing OOM on long sessions
- Store shape listener refs in Map, remove in unregisterShape() (critical leak) - Compact Automerge history every 500 changes via clone() to cap WASM heap - Clean shapeLastPos entries on shape removal - Store outside-click handler ref, clean up in disconnectedCallback() - Cap MI messages at 50 and prompt messages at 30 to prevent unbounded growth - Store keep-alive interval handle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f3d68b2ef5
commit
efbd0f040c
|
|
@ -157,6 +157,8 @@ export class CommunitySync extends EventTarget {
|
|||
#disconnectedIntentionally = false;
|
||||
#communitySlug: string;
|
||||
#shapes: Map<string, FolkShape> = new Map();
|
||||
#shapeListeners: Map<string, { transform: EventListener; content: EventListener }> = 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));
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export class RStackMi extends HTMLElement {
|
|||
#voiceDictation: SpeechDictation | null = null;
|
||||
#voiceAccumulated = "";
|
||||
#voiceSilenceTimer: ReturnType<typeof setTimeout> | 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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue