From 53c757e68ef4e4b2335c4c2f0d21090aa69415ea Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 10 Apr 2026 22:26:24 -0400 Subject: [PATCH] fix: comprehensive memory leak and performance fixes across 44 files Browser-side: - Fix switchSpace() to LRU-evict idle space WebSocket connections (cap: 3) - Add runtime.unsubscribe() to disconnectedCallback in 24 components - Fix DocSyncManager.unsubscribe() to clean up syncStates, timers, listeners - Fix 14 components leaking RAF loops, ResizeObservers, MutationObservers, document/window listeners, setIntervals, MapLibre WebGL contexts, and AbortControllers on disconnect - Deduplicate Automerge WASM: module builds now use global shim from shell-offline instead of bundling ~2.5MB each (8 modules affected) Server-side: - Add LRU eviction to SyncServer.#docs (cap: 500, evicts idle docs with no subscribers, persists to disk before eviction) - registerWatcher() now returns unsubscribe function Data: - Cap unbounded CRDT arrays: rexchange chatMessages (200), rcart events (200) Co-Authored-By: Claude Opus 4.6 --- lib/folk-comment-pin.ts | 7 +- lib/folk-commitment-pool.ts | 2 + lib/folk-design-agent.ts | 6 ++ lib/folk-drawfast.ts | 11 ++- lib/folk-makereal.ts | 11 ++- lib/folk-map.ts | 3 + lib/folk-piano.ts | 14 +++- lib/folk-splat.ts | 16 +++- .../components/folk-crowdsurf-dashboard.ts | 7 ++ modules/rbnb/components/folk-bnb-view.ts | 7 ++ modules/rbooks/components/folk-book-reader.ts | 23 ++++-- modules/rbooks/components/folk-book-shelf.ts | 7 ++ modules/rcal/components/folk-calendar-view.ts | 7 ++ modules/rcart/components/folk-cart-shop.ts | 8 ++ modules/rcart/mod.ts | 4 + .../components/folk-choices-dashboard.ts | 7 ++ modules/rdocs/components/folk-docs-app.ts | 7 ++ modules/rexchange/exchange-routes.ts | 6 ++ modules/rflows/components/folk-flows-app.ts | 24 +++++- .../rforum/components/folk-forum-dashboard.ts | 7 ++ modules/rmaps/components/folk-map-viewer.ts | 7 ++ modules/rmeets/components/folk-jitsi-room.ts | 7 ++ modules/rnetwork/components/folk-crm-view.ts | 7 ++ .../rnetwork/components/folk-graph-viewer.ts | 7 ++ .../rphotos/components/folk-photo-gallery.ts | 7 ++ .../rpubs/components/folk-pubs-flipbook.ts | 12 ++- .../rschedule/components/folk-schedule-app.ts | 7 ++ .../components/folk-campaign-manager.ts | 7 ++ .../components/folk-thread-builder.ts | 7 ++ .../components/folk-thread-gallery.ts | 7 ++ .../rsplat/components/folk-splat-viewer.ts | 7 ++ .../rswag/components/folk-swag-designer.ts | 7 ++ modules/rtime/components/folk-timebank-app.ts | 14 +++- modules/rtube/components/folk-video-player.ts | 9 +++ modules/rvnb/components/folk-vnb-view.ts | 7 ++ .../rwallet/components/folk-wallet-viewer.ts | 8 ++ server/local-first/sync-server.ts | 73 ++++++++++++++++++- server/sync-instance.ts | 6 ++ shared/automerge-shim.ts | 39 ++++++++++ shared/components/rstack-space-switcher.ts | 16 +++- shared/local-first/runtime.ts | 37 ++++++++++ shared/local-first/sync.ts | 11 +++ vite.config.ts | 59 ++++++--------- website/shell-offline.ts | 4 + 44 files changed, 490 insertions(+), 66 deletions(-) create mode 100644 shared/automerge-shim.ts diff --git a/lib/folk-comment-pin.ts b/lib/folk-comment-pin.ts index c93e9c6c..e336dcde 100644 --- a/lib/folk-comment-pin.ts +++ b/lib/folk-comment-pin.ts @@ -41,6 +41,7 @@ export class CommentPinManager { #members: SpaceMember[] | null = null; #mentionDropdown: HTMLElement | null = null; #notes: RNoteItem[] | null = null; + #onDocPointerDown: (e: PointerEvent) => void = () => {}; constructor( container: HTMLElement, @@ -87,13 +88,14 @@ export class CommentPinManager { }); // Close popover on outside click - document.addEventListener("pointerdown", (e) => { + this.#onDocPointerDown = (e: PointerEvent) => { if (this.#popover.style.display === "none") return; if (this.#popover.contains(e.target as Node)) return; // Don't close if clicking a pin marker if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return; this.closePopover(); - }); + }; + document.addEventListener("pointerdown", this.#onDocPointerDown); } // ── Camera ── @@ -973,6 +975,7 @@ export class CommentPinManager { } destroy() { + document.removeEventListener("pointerdown", this.#onDocPointerDown); this.#pinLayer.remove(); this.#popover.remove(); } diff --git a/lib/folk-commitment-pool.ts b/lib/folk-commitment-pool.ts index 5376a088..78fb550f 100644 --- a/lib/folk-commitment-pool.ts +++ b/lib/folk-commitment-pool.ts @@ -299,6 +299,8 @@ export class FolkCommitmentPool extends FolkShape { if (this.#animFrame) cancelAnimationFrame(this.#animFrame); this.#animFrame = 0; this.#removeGhost(); + document.removeEventListener("pointermove", this.#onDocPointerMove); + document.removeEventListener("pointerup", this.#onDocPointerUp); } // ── Data fetching ── diff --git a/lib/folk-design-agent.ts b/lib/folk-design-agent.ts index b99ac7ae..2246255a 100644 --- a/lib/folk-design-agent.ts +++ b/lib/folk-design-agent.ts @@ -460,6 +460,12 @@ export class FolkDesignAgent extends FolkShape { this.#setState("idle"); this.#addStep("!", "error", "Stopped by user"); } + + override disconnectedCallback() { + super.disconnectedCallback?.(); + this.#abortController?.abort(); + this.#abortController = null; + } } if (!customElements.get(FolkDesignAgent.tagName)) { diff --git a/lib/folk-drawfast.ts b/lib/folk-drawfast.ts index 6d7f0c49..a4d61034 100644 --- a/lib/folk-drawfast.ts +++ b/lib/folk-drawfast.ts @@ -595,6 +595,7 @@ export class FolkDrawfast extends FolkShape { #resultArea: HTMLElement | null = null; #canvasArea: HTMLElement | null = null; #gestureEnabled = true; + #resizeObserver: ResizeObserver | null = null; get strokes() { return this.#strokes; @@ -832,11 +833,11 @@ export class FolkDrawfast extends FolkShape { }); // Watch for resize - const ro = new ResizeObserver(() => { + this.#resizeObserver = new ResizeObserver(() => { this.#resizeCanvas(canvasArea); this.#redraw(); }); - ro.observe(canvasArea); + this.#resizeObserver.observe(canvasArea); return root; } @@ -1133,6 +1134,12 @@ export class FolkDrawfast extends FolkShape { return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + override disconnectedCallback() { + super.disconnectedCallback?.(); + this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; + } + static override fromData(data: Record): FolkDrawfast { const shape = FolkShape.fromData(data) as FolkDrawfast; return shape; diff --git a/lib/folk-makereal.ts b/lib/folk-makereal.ts index 39435c17..dea086c8 100644 --- a/lib/folk-makereal.ts +++ b/lib/folk-makereal.ts @@ -351,6 +351,7 @@ export class FolkMakereal extends FolkShape { #promptInput: HTMLInputElement | null = null; #generateBtn: HTMLButtonElement | null = null; #resultArea: HTMLElement | null = null; + #resizeObserver: ResizeObserver | null = null; override createRenderRoot() { const root = super.createRenderRoot(); @@ -524,11 +525,11 @@ export class FolkMakereal extends FolkShape { this.#redraw(); }); - const ro = new ResizeObserver(() => { + this.#resizeObserver = new ResizeObserver(() => { this.#resizeCanvas(canvasArea); this.#redraw(); }); - ro.observe(canvasArea); + this.#resizeObserver.observe(canvasArea); return root; } @@ -738,6 +739,12 @@ export class FolkMakereal extends FolkShape { return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + override disconnectedCallback() { + super.disconnectedCallback?.(); + this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; + } + static override fromData(data: Record): FolkMakereal { const shape = FolkShape.fromData(data) as FolkMakereal; return shape; diff --git a/lib/folk-map.ts b/lib/folk-map.ts index 1f499996..0a6af16c 100644 --- a/lib/folk-map.ts +++ b/lib/folk-map.ts @@ -434,6 +434,7 @@ interface MapLibreMap { getZoom(): number; addControl(control: unknown, position?: string): void; on(event: string, handler: (e: MapLibreEvent) => void): void; + remove(): void; } interface MapLibreEvent { @@ -818,6 +819,8 @@ export class FolkMap extends FolkShape { disconnectedCallback() { super.disconnectedCallback?.(); this.#leaveRoom(); + this.#map?.remove(); + this.#map = null; } // ── MapLibre loading ── diff --git a/lib/folk-piano.ts b/lib/folk-piano.ts index c42114c1..eeeda1ca 100644 --- a/lib/folk-piano.ts +++ b/lib/folk-piano.ts @@ -154,6 +154,7 @@ export class FolkPiano extends FolkShape { #errorEl: HTMLElement | null = null; #minimizedEl: HTMLElement | null = null; #containerEl: HTMLElement | null = null; + #onWindowError: ((e: ErrorEvent) => void) | null = null; get isMinimized() { return this.#isMinimized; @@ -249,11 +250,12 @@ export class FolkPiano extends FolkShape { }); // Suppress Chrome Music Lab console errors - window.addEventListener("error", (e) => { + this.#onWindowError = (e: ErrorEvent) => { if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) { e.preventDefault(); } - }); + }; + window.addEventListener("error", this.#onWindowError); return root; } @@ -281,6 +283,14 @@ export class FolkPiano extends FolkShape { this.#iframe.src = PIANO_URL; } + override disconnectedCallback() { + super.disconnectedCallback?.(); + if (this.#onWindowError) { + window.removeEventListener("error", this.#onWindowError); + this.#onWindowError = null; + } + } + static override fromData(data: Record): FolkPiano { const shape = FolkShape.fromData(data) as FolkPiano; if (data.isMinimized != null) shape.isMinimized = data.isMinimized; diff --git a/lib/folk-splat.ts b/lib/folk-splat.ts index 6f1f4a6d..e7808c4c 100644 --- a/lib/folk-splat.ts +++ b/lib/folk-splat.ts @@ -237,6 +237,7 @@ export class FolkSplat extends FolkShape { #viewerCanvas: HTMLCanvasElement | null = null; #gallerySplats: any[] = []; #urlInput: HTMLInputElement | null = null; + #rafId: number | null = null; get splatUrl() { return this.#splatUrl; @@ -403,7 +404,7 @@ export class FolkSplat extends FolkShape { if (!this.#viewer) return; (viewer as any).update(); (viewer as any).render(); - requestAnimationFrame(animate); + this.#rafId = requestAnimationFrame(animate); }; animate(); } catch (err) { @@ -436,6 +437,19 @@ export class FolkSplat extends FolkShape { }; } + override disconnectedCallback() { + super.disconnectedCallback?.(); + if (this.#rafId !== null) { + cancelAnimationFrame(this.#rafId); + this.#rafId = null; + } + if (this.#viewer) { + try { (this.#viewer as any).dispose?.(); } catch { /* ignore */ } + this.#viewer = null; + } + this.#viewerCanvas = null; + } + override applyData(data: Record): void { super.applyData(data); if ("splatUrl" in data && this.splatUrl !== data.splatUrl) { diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts index 3992390a..d16cc0bf 100644 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -73,6 +73,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { // Multiplayer private lfClient: CrowdSurfLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; // Expiry timer @@ -108,6 +109,11 @@ class FolkCrowdSurfDashboard extends HTMLElement { this._lfcUnsub = null; this.lfClient?.disconnect(); if (this._expiryTimer !== null) clearInterval(this._expiryTimer); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -158,6 +164,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { try { const docId = crowdsurfDocId(this.space) as DocumentId; await runtime.subscribe(docId, crowdsurfSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts index acee44ea..286f96bd 100644 --- a/modules/rbnb/components/folk-bnb-view.ts +++ b/modules/rbnb/components/folk-bnb-view.ts @@ -73,6 +73,7 @@ class FolkBnbView extends HTMLElement { #tour: LightTourEngine | null = null; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; connectedCallback() { this.#space = this.getAttribute('space') || 'demo'; @@ -88,6 +89,11 @@ class FolkBnbView extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -103,6 +109,7 @@ class FolkBnbView extends HTMLElement { try { const docId = bnbDocId(this.#space) as DocumentId; await runtime.subscribe(docId, bnbSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rbooks/components/folk-book-reader.ts b/modules/rbooks/components/folk-book-reader.ts index 141dae5d..fa7c238e 100644 --- a/modules/rbooks/components/folk-book-reader.ts +++ b/modules/rbooks/components/folk-book-reader.ts @@ -40,6 +40,9 @@ export class FolkBookReader extends HTMLElement { private _error: string | null = null; private _flipBook: any = null; private _db: IDBDatabase | null = null; + private _keyHandler: ((e: KeyboardEvent) => void) | null = null; + private _resizeHandler: (() => void) | null = null; + private _resizeTimer: number | null = null; static get observedAttributes() { return ["pdf-url", "book-id", "title", "author"]; @@ -90,6 +93,9 @@ export class FolkBookReader extends HTMLElement { localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage)); this._flipBook?.destroy(); this._db?.close(); + if (this._keyHandler) { document.removeEventListener("keydown", this._keyHandler); this._keyHandler = null; } + if (this._resizeHandler) { window.removeEventListener("resize", this._resizeHandler); this._resizeHandler = null; } + if (this._resizeTimer !== null) { clearTimeout(this._resizeTimer); this._resizeTimer = null; } } // ── IndexedDB cache ── @@ -340,17 +346,20 @@ export class FolkBookReader extends HTMLElement { }); // Keyboard nav - document.addEventListener("keydown", (e) => { + if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler); + this._keyHandler = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") this._flipBook?.flipPrev(); else if (e.key === "ArrowRight") this._flipBook?.flipNext(); - }); + }; + document.addEventListener("keydown", this._keyHandler); // Resize handler - let resizeTimer: number; - window.addEventListener("resize", () => { - clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(() => this.renderReader(), 250); - }); + if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler); + this._resizeHandler = () => { + if (this._resizeTimer !== null) clearTimeout(this._resizeTimer); + this._resizeTimer = window.setTimeout(() => this.renderReader(), 250); + }; + window.addEventListener("resize", this._resizeHandler); } private updatePageCounter() { diff --git a/modules/rbooks/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts index 32f780a6..f68f764a 100644 --- a/modules/rbooks/components/folk-book-shelf.ts +++ b/modules/rbooks/components/folk-book-shelf.ts @@ -38,6 +38,7 @@ export class FolkBookShelf extends HTMLElement { private _searchTerm = ""; private _selectedTag: string | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -90,6 +91,11 @@ export class FolkBookShelf extends HTMLElement { this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async subscribeOffline() { @@ -99,6 +105,7 @@ export class FolkBookShelf extends HTMLElement { try { const docId = booksCatalogDocId(this._spaceSlug) as DocumentId; const doc = await runtime.subscribe(docId, booksCatalogSchema); + this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 24c0336f..071b30d6 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -127,6 +127,7 @@ class FolkCalendarView extends HTMLElement { private filteredSources = new Set(); private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; // Spatio-temporal state @@ -219,6 +220,11 @@ class FolkCalendarView extends HTMLElement { this._offlineUnsub?.(); this._offlineUnsub = null; this._stopPresence?.(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; if (this.boundKeyHandler) { document.removeEventListener("keydown", this.boundKeyHandler); this.boundKeyHandler = null; @@ -247,6 +253,7 @@ class FolkCalendarView extends HTMLElement { try { const docId = calendarDocId(this.space) as DocumentId; const doc = await runtime.subscribe(docId, calendarSchema); + this._subscribedDocIds.push(docId); this.renderFromCalDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index c25ade89..e5ee4a7b 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -38,6 +38,7 @@ class FolkCartShop extends HTMLElement { private creatingPayment = false; private creatingGroupBuy = false; private _offlineUnsubs: (() => void)[] = []; + private _subscribedDocIds: string[] = []; private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart"); private _stopPresence: (() => void) | null = null; @@ -100,6 +101,11 @@ class FolkCartShop extends HTMLElement { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async subscribeOffline() { @@ -110,6 +116,7 @@ class FolkCartShop extends HTMLElement { // Subscribe to catalog const catDocId = catalogDocId(this.space) as DocumentId; const catDoc = await runtime.subscribe(catDocId, catalogSchema); + this._subscribedDocIds.push(catDocId); if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) { this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({ id: item.id, title: item.title, description: '', @@ -135,6 +142,7 @@ class FolkCartShop extends HTMLElement { // Subscribe to shopping cart index const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId; const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema); + this._subscribedDocIds.push(indexDocId); if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) { this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c })); this.render(); diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index fc39fdd8..15fe0fde 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1116,6 +1116,7 @@ routes.post("/api/shopping-carts/:cartId/items", async (c) => { detail: `Added ${productData.name || 'item'}`, timestamp: now, }); + if (d.events.length > 200) d.events.splice(0, d.events.length - 200); }); reindexCart(space, cartId); @@ -1172,6 +1173,7 @@ routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => { detail: `Removed ${itemName}`, timestamp: Date.now(), }); + if (d.events.length > 200) d.events.splice(0, d.events.length - 200); }); reindexCart(space, cartId); @@ -1212,6 +1214,7 @@ routes.post("/api/shopping-carts/:cartId/contribute", async (c) => { detail: `Contributed $${amount.toFixed(2)}`, timestamp: now, }); + if (d.events.length > 200) d.events.splice(0, d.events.length - 200); }); reindexCart(space, cartId); @@ -1725,6 +1728,7 @@ routes.patch("/api/payments/:id/status", async (c) => { detail: `Paid $${contribAmount.toFixed(2)} via ${updated!.payment.paymentMethod || 'wallet'}`, timestamp: contribNow, }); + if (d.events.length > 200) d.events.splice(0, d.events.length - 200); }); reindexCart(space, linkedCartId); } diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index 2e224c71..f897d6f0 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -57,6 +57,7 @@ class FolkChoicesDashboard extends HTMLElement { /* Multiplayer state */ private lfClient: ChoicesLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; private sessions: ChoiceSession[] = []; private activeSessionId: string | null = null; @@ -119,6 +120,11 @@ class FolkChoicesDashboard extends HTMLElement { this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async initMultiplayer() { @@ -157,6 +163,7 @@ class FolkChoicesDashboard extends HTMLElement { try { const docId = choicesDocId(this.space) as DocumentId; await runtime.subscribe(docId, choicesSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rdocs/components/folk-docs-app.ts b/modules/rdocs/components/folk-docs-app.ts index b4607dde..d90bb1ae 100644 --- a/modules/rdocs/components/folk-docs-app.ts +++ b/modules/rdocs/components/folk-docs-app.ts @@ -186,6 +186,7 @@ class FolkDocsApp extends HTMLElement { private syncConnected = false; private _offlineUnsub: (() => void) | null = null; private _offlineNotebookUnsubs: (() => void)[] = []; + private _subscribedDocIds: string[] = []; // ── Presence indicators ── private _presencePeers: Map

Maya is tracking expenses in rF this._offlineUnsub = null; for (const unsub of this._offlineNotebookUnsubs) unsub(); this._offlineNotebookUnsubs = []; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } // ── Sync (via shared runtime) ── @@ -629,6 +635,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF try { const docId = this.subscribedDocId as DocumentId; const doc = await runtime.subscribe(docId, notebookSchema); + this._subscribedDocIds.push(docId); this.doc = doc; this.renderFromDoc(); diff --git a/modules/rexchange/exchange-routes.ts b/modules/rexchange/exchange-routes.ts index be30c0b8..bf61d984 100644 --- a/modules/rexchange/exchange-routes.ts +++ b/modules/rexchange/exchange-routes.ts @@ -488,6 +488,7 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) { if (!body.text?.trim()) return c.json({ error: 'text required' }, 400); const msgId = crypto.randomUUID(); + const MAX_CHAT_MESSAGES = 200; ss().changeDoc(exchangeTradesDocId(space), 'trade chat message', (d) => { if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any; (d.trades[id].chatMessages as any[]).push({ @@ -497,6 +498,11 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) { text: body.text.trim(), timestamp: Date.now(), }); + // Cap chat history to prevent unbounded CRDT growth + const msgs = d.trades[id].chatMessages as any[]; + if (msgs.length > MAX_CHAT_MESSAGES) { + msgs.splice(0, msgs.length - MAX_CHAT_MESSAGES); + } }); const updated = ss().getDoc(exchangeTradesDocId(space))!; diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 47c6b5e0..825bd91c 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -90,6 +90,7 @@ class FolkFlowsApp extends HTMLElement { private loading = false; private error = ""; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; // Canvas state private canvasZoom = 1; @@ -201,6 +202,10 @@ class FolkFlowsApp extends HTMLElement { // Tour engine private _tour!: TourEngine; + // Cleanup handles + private _themeChangeHandler: (() => void) | null = null; + private _mutationObserver: MutationObserver | null = null; + private static readonly TOUR_STEPS = [ { target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true }, { target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true }, @@ -227,9 +232,10 @@ class FolkFlowsApp extends HTMLElement { // Mirror document theme to host for shadow DOM CSS selectors this._syncTheme(); - document.addEventListener("theme-change", () => this._syncTheme()); - new MutationObserver(() => this._syncTheme()) - .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + this._themeChangeHandler = () => this._syncTheme(); + document.addEventListener("theme-change", this._themeChangeHandler); + this._mutationObserver = new MutationObserver(() => this._syncTheme()); + this._mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); // Read view attribute, default to canvas (detail) view const viewAttr = this.getAttribute("view"); @@ -364,6 +370,11 @@ class FolkFlowsApp extends HTMLElement { disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; this._lfcUnsub?.(); this._lfcUnsub = null; this._stopPresence?.(); @@ -372,6 +383,12 @@ class FolkFlowsApp extends HTMLElement { if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; } this.localFirstClient?.disconnect(); + if (this._themeChangeHandler) { + document.removeEventListener("theme-change", this._themeChangeHandler); + this._themeChangeHandler = null; + } + this._mutationObserver?.disconnect(); + this._mutationObserver = null; } // ─── Auto-save (debounced) ────────────────────────────── @@ -533,6 +550,7 @@ class FolkFlowsApp extends HTMLElement { try { const docId = flowsDocId(this.space) as DocumentId; const doc = await runtime.subscribe(docId, flowsSchema); + this._subscribedDocIds.push(docId); // Render cached flow associations immediately this.renderFlowsFromDoc(doc); diff --git a/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index 8ca1c946..a8047605 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -21,6 +21,7 @@ class FolkForumDashboard extends HTMLElement { private pollTimer: number | null = null; private space = ""; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum"); private _tour!: TourEngine; @@ -73,6 +74,11 @@ class FolkForumDashboard extends HTMLElement { if (this.pollTimer) clearInterval(this.pollTimer); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async subscribeOffline() { @@ -83,6 +89,7 @@ class FolkForumDashboard extends HTMLElement { // Forum uses a global doc (not space-scoped) const docId = FORUM_DOC_ID as DocumentId; const doc = await runtime.subscribe(docId, forumSchema); + this._subscribedDocIds.push(docId); if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) { this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({ diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index d5d73103..a051be5b 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -149,6 +149,7 @@ class FolkMapViewer extends HTMLElement { private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps"); private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; // Chat + Local-first state private lfClient: MapsLocalFirstClient | null = null; @@ -226,6 +227,11 @@ class FolkMapViewer extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; } this.leaveRoom(); if (this._themeObserver) { @@ -251,6 +257,7 @@ class FolkMapViewer extends HTMLElement { try { const docId = mapsDocId(this.space) as DocumentId; await runtime.subscribe(docId, mapsSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts index 5d1b5d34..117fb46d 100644 --- a/modules/rmeets/components/folk-jitsi-room.ts +++ b/modules/rmeets/components/folk-jitsi-room.ts @@ -28,6 +28,7 @@ class FolkJitsiRoom extends HTMLElement { private directorError = ""; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; constructor() { super(); @@ -52,6 +53,11 @@ class FolkJitsiRoom extends HTMLElement { disconnectedCallback() { this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; this.dispose(); } @@ -61,6 +67,7 @@ class FolkJitsiRoom extends HTMLElement { try { const docId = meetsDocId(this.space) as DocumentId; await runtime.subscribe(docId, meetsSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index e8ebde55..d7a55207 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -108,6 +108,7 @@ class FolkCrmView extends HTMLElement { private graphSelectedId: string | null = null; private _stopPresence: (() => void) | null = null; + private _subscribedDocIds: string[] = []; // Guided tour private _tour!: TourEngine; @@ -175,6 +176,11 @@ class FolkCrmView extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); document.removeEventListener("rapp-tab-change", this._onTabChange); this._stopPresence?.(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -190,6 +196,7 @@ class FolkCrmView extends HTMLElement { try { const docId = networkDocId(this.space) as DocumentId; await runtime.subscribe(docId, networkSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 79c877a3..9502f774 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -169,6 +169,7 @@ class FolkGraphViewer extends HTMLElement { private _textSpriteCache = new Map(); private _badgeSpriteCache = new Map(); private _stopPresence: (() => void) | null = null; + private _subscribedDocIds: string[] = []; // Local-first client for layer config persistence private _lfClient: NetworkLocalFirstClient | null = null; @@ -237,6 +238,11 @@ class FolkGraphViewer extends HTMLElement { this.graph._destructor?.(); this.graph = null; } + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async subscribeCollabOverlay() { @@ -245,6 +251,7 @@ class FolkGraphViewer extends HTMLElement { try { const docId = networkDocId(this.space) as DocumentId; await runtime.subscribe(docId, networkSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rphotos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts index 5d28ae81..b65ae474 100644 --- a/modules/rphotos/components/folk-photo-gallery.ts +++ b/modules/rphotos/components/folk-photo-gallery.ts @@ -54,6 +54,7 @@ class FolkPhotoGallery extends HTMLElement { private _tour!: TourEngine; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos"); private static readonly TOUR_STEPS = [ { target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false }, @@ -90,6 +91,11 @@ class FolkPhotoGallery extends HTMLElement { disconnectedCallback() { this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; this._history.destroy(); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); } @@ -108,6 +114,7 @@ class FolkPhotoGallery extends HTMLElement { try { const docId = photosDocId(this.space) as DocumentId; await runtime.subscribe(docId, photosSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rpubs/components/folk-pubs-flipbook.ts b/modules/rpubs/components/folk-pubs-flipbook.ts index 3112d601..5a12e4e7 100644 --- a/modules/rpubs/components/folk-pubs-flipbook.ts +++ b/modules/rpubs/components/folk-pubs-flipbook.ts @@ -25,6 +25,7 @@ export class FolkPubsFlipbook extends HTMLElement { private _flipBook: any = null; private _keyHandler: ((e: KeyboardEvent) => void) | null = null; private _resizeTimer: ReturnType | null = null; + private _resizeHandler: (() => void) | null = null; static get observedAttributes() { return ["pdf-url"]; @@ -46,8 +47,9 @@ export class FolkPubsFlipbook extends HTMLElement { disconnectedCallback() { this._flipBook?.destroy(); - if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler); - if (this._resizeTimer) clearTimeout(this._resizeTimer); + if (this._keyHandler) { document.removeEventListener("keydown", this._keyHandler); this._keyHandler = null; } + if (this._resizeTimer) { clearTimeout(this._resizeTimer); this._resizeTimer = null; } + if (this._resizeHandler) { window.removeEventListener("resize", this._resizeHandler); this._resizeHandler = null; } } private async loadPDF() { @@ -294,10 +296,12 @@ export class FolkPubsFlipbook extends HTMLElement { }; document.addEventListener("keydown", this._keyHandler); - window.addEventListener("resize", () => { + if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler); + this._resizeHandler = () => { if (this._resizeTimer) clearTimeout(this._resizeTimer); this._resizeTimer = setTimeout(() => this.renderReader(), 250); - }); + }; + window.addEventListener("resize", this._resizeHandler); } private renderFallback() { diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts index 5fc730c0..eec1f2ab 100644 --- a/modules/rschedule/components/folk-schedule-app.ts +++ b/modules/rschedule/components/folk-schedule-app.ts @@ -91,6 +91,7 @@ class FolkScheduleApp extends HTMLElement { private loading = false; private runningJobId: string | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -148,6 +149,11 @@ class FolkScheduleApp extends HTMLElement { this._offlineUnsub = null; } this._stopPresence?.(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private async subscribeOffline() { @@ -157,6 +163,7 @@ class FolkScheduleApp extends HTMLElement { try { const docId = scheduleDocId(this.space) as DocumentId; const doc = await runtime.subscribe(docId, scheduleSchema); + this._subscribedDocIds.push(docId); if (doc) this.renderFromDoc(doc as ScheduleDoc); this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => { diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index e8a4cd15..32b7303b 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -15,6 +15,7 @@ export class FolkCampaignManager extends HTMLElement { private _space = 'demo'; private _campaigns: Campaign[] = []; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; // AI generation state private _generatedCampaign: Campaign | null = null; @@ -58,6 +59,11 @@ export class FolkCampaignManager extends HTMLElement { disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } attributeChangedCallback(name: string, _old: string, val: string) { @@ -71,6 +77,7 @@ export class FolkCampaignManager extends HTMLElement { try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); + this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts index 521b79aa..044da990 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 _subscribedDocIds: string[] = []; private _offlineReady: Promise | null = null; private _tweetImageUploadIdx: string | null = null; private _linkPreviewCache: Map = new Map(); @@ -72,6 +73,11 @@ export class FolkThreadBuilder extends HTMLElement { this._offlineUnsub?.(); this._offlineUnsub = null; if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } attributeChangedCallback(name: string, _old: string, val: string) { @@ -111,6 +117,7 @@ export class FolkThreadBuilder extends HTMLElement { try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); + this._subscribedDocIds.push(docId); if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) { // Deep-clone to get a plain object (not an Automerge proxy) diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index 0fc7e9cf..2b47e6c1 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -27,6 +27,7 @@ export class FolkThreadGallery extends HTMLElement { private _threads: ThreadData[] = []; private _draftPosts: DraftPostCard[] = []; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _isDemoFallback = false; static get observedAttributes() { return ['space']; } @@ -41,6 +42,11 @@ export class FolkThreadGallery extends HTMLElement { disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } attributeChangedCallback(name: string, _old: string, val: string) { @@ -62,6 +68,7 @@ export class FolkThreadGallery extends HTMLElement { try { const docId = socialsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, socialsSchema); + this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index 81e33363..f191b310 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -54,6 +54,7 @@ export class FolkSplatViewer extends HTMLElement { private _inlineViewer = false; private _offlineUnsub: (() => void) | null = null; private _stopPresence: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _generatedUrl = ""; private _generatedTitle = ""; private _savedSlug = ""; @@ -100,6 +101,7 @@ export class FolkSplatViewer extends HTMLElement { try { const docId = splatScenesDocId(this._spaceSlug) as DocumentId; const doc = await runtime.subscribe(docId, splatScenesSchema); + this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { @@ -155,6 +157,11 @@ export class FolkSplatViewer extends HTMLElement { this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts index 8940c7d8..c685898b 100644 --- a/modules/rswag/components/folk-swag-designer.ts +++ b/modules/rswag/components/folk-swag-designer.ts @@ -246,6 +246,7 @@ class FolkSwagDesigner extends HTMLElement { private lfClient: SwagLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; private _stopPresence: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private sharedDesigns: SwagDesign[] = []; private _tour!: TourEngine; @@ -296,6 +297,11 @@ class FolkSwagDesigner extends HTMLElement { this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -330,6 +336,7 @@ class FolkSwagDesigner extends HTMLElement { try { const docId = swagDocId(this.space) as DocumentId; await runtime.subscribe(docId, swagSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 8e4e0581..5a1e70d3 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -328,6 +328,8 @@ class FolkTimebankApp extends HTMLElement { private _theme: 'dark' | 'light' = 'dark'; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; + private _resizeObserver: ResizeObserver | null = null; constructor() { super(); @@ -368,6 +370,7 @@ class FolkTimebankApp extends HTMLElement { try { const docId = commitmentsDocId(this.space) as DocumentId; await runtime.subscribe(docId, commitmentsSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } @@ -399,6 +402,13 @@ class FolkTimebankApp extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); if (this.animFrame) cancelAnimationFrame(this.animFrame); this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; + this._resizeObserver?.disconnect(); + this._resizeObserver = null; } private _onViewRestored = (e: CustomEvent) => { @@ -806,8 +816,8 @@ class FolkTimebankApp extends HTMLElement { }); // Resize — now targets pool panel - const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); }); - resizeObserver.observe(this); + this._resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); }); + this._resizeObserver.observe(this); this.resizePoolCanvas(); this.poolFrame(); diff --git a/modules/rtube/components/folk-video-player.ts b/modules/rtube/components/folk-video-player.ts index d9ffeb26..b3e03d11 100644 --- a/modules/rtube/components/folk-video-player.ts +++ b/modules/rtube/components/folk-video-player.ts @@ -39,6 +39,7 @@ class FolkVideoPlayer extends HTMLElement { private expandedView: number | null = null; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false }, @@ -73,6 +74,13 @@ class FolkVideoPlayer extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + if (this.splitPollInterval) { clearInterval(this.splitPollInterval); this.splitPollInterval = null; } + if (this.liveSplitStatusInterval) { clearInterval(this.liveSplitStatusInterval); this.liveSplitStatusInterval = null; } + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -90,6 +98,7 @@ class FolkVideoPlayer extends HTMLElement { try { const docId = tubeDocId(this.space) as DocumentId; await runtime.subscribe(docId, tubeSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rvnb/components/folk-vnb-view.ts b/modules/rvnb/components/folk-vnb-view.ts index 1f760f5c..7cfe2217 100644 --- a/modules/rvnb/components/folk-vnb-view.ts +++ b/modules/rvnb/components/folk-vnb-view.ts @@ -78,6 +78,7 @@ class FolkVnbView extends HTMLElement { #tour: LightTourEngine | null = null; private _stopPresence: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; connectedCallback() { this.#space = this.getAttribute('space') || 'demo'; @@ -93,6 +94,11 @@ class FolkVnbView extends HTMLElement { window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -108,6 +114,7 @@ class FolkVnbView extends HTMLElement { try { const docId = vnbDocId(this.#space) as DocumentId; await runtime.subscribe(docId, vnbSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 81750585..915966f1 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -226,6 +226,7 @@ class FolkWalletViewer extends HTMLElement { // Multiplayer state private lfClient: WalletLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; private watchedAddresses: WatchedAddress[] = []; private _stopPresence: (() => void) | null = null; @@ -280,6 +281,12 @@ class FolkWalletViewer extends HTMLElement { clearInterval(this.flowsPlayInterval); this.flowsPlayInterval = null; } + if (this._quoteTimer) { clearTimeout(this._quoteTimer); this._quoteTimer = null; } + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; } private _onViewRestored = (e: CustomEvent) => { @@ -341,6 +348,7 @@ class FolkWalletViewer extends HTMLElement { try { const docId = walletDocId(this.space) as DocumentId; await runtime.subscribe(docId, walletSchema); + this._subscribedDocIds.push(docId); } catch { /* runtime unavailable */ } } diff --git a/server/local-first/sync-server.ts b/server/local-first/sync-server.ts index 8bb85f29..6827e5e9 100644 --- a/server/local-first/sync-server.ts +++ b/server/local-first/sync-server.ts @@ -104,6 +104,10 @@ export interface SyncServerOptions { onRelayBackup?: (docId: string, blob: Uint8Array) => void; /** Called to load a relay blob for restore on subscribe */ onRelayLoad?: (docId: string) => Promise; + /** Called before evicting a doc from memory (persist to disk) */ + onDocEvict?: (docId: string, doc: Automerge.Doc) => void; + /** Max docs to keep in memory; 0 = unlimited (default: 500) */ + maxDocs?: number; } // ============================================================================ @@ -114,18 +118,25 @@ export class SyncServer { #peers = new Map(); #docs = new Map>(); #docSubscribers = new Map>(); // docId → Set + #docLastAccess = new Map(); // docId → timestamp #participantMode: boolean; #relayOnlyDocs = new Set(); // docIds forced to relay mode (encrypted spaces) #watchers: Array<{ prefix: string; cb: (docId: string, doc: Automerge.Doc) => void }> = []; #onDocChange?: (docId: string, doc: Automerge.Doc) => void; #onRelayBackup?: (docId: string, blob: Uint8Array) => void; #onRelayLoad?: (docId: string) => Promise; + /** Called before evicting a doc so it can be persisted to disk. */ + #onDocEvict?: (docId: string, doc: Automerge.Doc) => void; + /** Max docs to keep in memory (0 = unlimited). */ + #maxDocs: number; constructor(opts: SyncServerOptions = {}) { this.#participantMode = opts.participantMode ?? true; this.#onDocChange = opts.onDocChange; this.#onRelayBackup = opts.onRelayBackup; this.#onRelayLoad = opts.onRelayLoad; + this.#onDocEvict = opts.onDocEvict; + this.#maxDocs = opts.maxDocs ?? 500; } /** @@ -231,6 +242,7 @@ export class SyncServer { * Get a server-side document (participant mode). */ getDoc(docId: string): Automerge.Doc | undefined { + if (this.#docs.has(docId)) this.#touchDoc(docId); return this.#docs.get(docId); } @@ -246,10 +258,12 @@ export class SyncServer { */ setDoc(docId: string, doc: Automerge.Doc): void { this.#docs.set(docId, doc); + this.#touchDoc(docId); this.#syncDocToAllPeers(docId); if (this.#onDocChange) { this.#onDocChange(docId, doc); } + this.#evictIfNeeded(); } /** @@ -261,6 +275,7 @@ export class SyncServer { doc = Automerge.change(doc, message, fn as any); this.#docs.set(docId, doc); + this.#touchDoc(docId); this.#syncDocToAllPeers(docId); if (this.#onDocChange) { @@ -290,9 +305,15 @@ export class SyncServer { /** * Register a callback to fire when any doc whose ID contains `prefix` changes. + * Returns an unsubscribe function. */ - registerWatcher(prefix: string, cb: (docId: string, doc: Automerge.Doc) => void): void { - this.#watchers.push({ prefix, cb }); + registerWatcher(prefix: string, cb: (docId: string, doc: Automerge.Doc) => void): () => void { + const entry = { prefix, cb }; + this.#watchers.push(entry); + return () => { + const idx = this.#watchers.indexOf(entry); + if (idx >= 0) this.#watchers.splice(idx, 1); + }; } /** @@ -375,6 +396,7 @@ export class SyncServer { // Create an empty doc if this is the first time we see this docId doc = Automerge.init(); this.#docs.set(docId, doc); + this.#evictIfNeeded(); } let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState(); @@ -382,6 +404,7 @@ export class SyncServer { const changed = newDoc !== doc; this.#docs.set(docId, newDoc); + this.#touchDoc(docId); peer.syncStates.set(docId, newSyncState); // Send response sync message back to this peer @@ -493,4 +516,50 @@ export class SyncServer { console.error(`[SyncServer] Failed to send to peer ${peer.id}:`, e); } } + + /** Mark a doc as recently accessed for LRU tracking. */ + #touchDoc(docId: string): void { + this.#docLastAccess.set(docId, Date.now()); + } + + /** + * Evict least-recently-used docs that have no active subscribers. + * Called after adding new docs to keep memory bounded. + */ + #evictIfNeeded(): void { + if (this.#maxDocs <= 0 || this.#docs.size <= this.#maxDocs) return; + + // Build list of evictable docs (no active subscribers) + const evictable: Array<{ docId: string; lastAccess: number }> = []; + for (const docId of this.#docs.keys()) { + const subs = this.#docSubscribers.get(docId); + if (subs && subs.size > 0) continue; // has active subscribers, skip + evictable.push({ + docId, + lastAccess: this.#docLastAccess.get(docId) ?? 0, + }); + } + + // Sort by oldest access first + evictable.sort((a, b) => a.lastAccess - b.lastAccess); + + // Evict until under the cap + const toEvict = this.#docs.size - this.#maxDocs; + let evicted = 0; + for (const { docId } of evictable) { + if (evicted >= toEvict) break; + + const doc = this.#docs.get(docId); + if (doc && this.#onDocEvict) { + this.#onDocEvict(docId, doc); + } + this.#docs.delete(docId); + this.#docLastAccess.delete(docId); + evicted++; + } + + if (evicted > 0) { + console.log(`[SyncServer] Evicted ${evicted} idle docs (${this.#docs.size} remaining)`); + } + } } diff --git a/server/sync-instance.ts b/server/sync-instance.ts index 92670ab9..5fd2df87 100644 --- a/server/sync-instance.ts +++ b/server/sync-instance.ts @@ -32,10 +32,16 @@ function getEncryptionKeyId(docId: string): string | undefined { export const syncServer = new SyncServer({ participantMode: true, + maxDocs: 500, onDocChange: (docId, doc) => { const encryptionKeyId = getEncryptionKeyId(docId); saveDoc(docId, doc, encryptionKeyId); }, + onDocEvict: (docId, doc) => { + // Persist to disk before evicting from memory + const encryptionKeyId = getEncryptionKeyId(docId); + saveDoc(docId, doc, encryptionKeyId); + }, onRelayBackup: (docId, blob) => { saveEncryptedBlob(docId, blob); }, diff --git a/shared/automerge-shim.ts b/shared/automerge-shim.ts new file mode 100644 index 00000000..5f9b0a64 --- /dev/null +++ b/shared/automerge-shim.ts @@ -0,0 +1,39 @@ +/** + * Automerge browser shim — resolves to the globally-loaded Automerge instance + * exposed by shell-offline.ts via window.__automerge. + * + * Module builds alias '@automerge/automerge' to this file to avoid + * re-bundling the full Automerge + WASM (~2.5MB) in every module. + */ +const Automerge = (window as any).__automerge; +if (!Automerge) { + console.warn('[automerge-shim] Automerge not loaded yet — shell-offline may not have initialized'); +} +export default Automerge; +export const { + init, + from, + change, + emptyChange, + load, + save, + merge, + getActorId, + getHeads, + getHistory, + getChanges, + getAllChanges, + applyChanges, + generateSyncMessage, + receiveSyncMessage, + initSyncState, + encodeSyncState, + decodeSyncState, + clone, + free, + getConflicts, + getMissingDeps, + equals, + dump, + toJS, +} = Automerge ?? {}; diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index ce1101e6..7116caca 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -32,6 +32,7 @@ export class RStackSpaceSwitcher extends HTMLElement { #createFormOpen = false; #createName = ''; #createVisibility = 'public'; + #onDocPointerDown: ((e: PointerEvent) => void) | null = null; constructor() { super(); @@ -58,6 +59,13 @@ export class RStackSpaceSwitcher extends HTMLElement { this.#render(); } + disconnectedCallback() { + if (this.#onDocPointerDown) { + document.removeEventListener("pointerdown", this.#onDocPointerDown); + this.#onDocPointerDown = null; + } + } + async #loadSpaces() { if (this.#loaded) return; try { @@ -123,13 +131,17 @@ export class RStackSpaceSwitcher extends HTMLElement { }); // Close menu only when clicking outside both trigger and menu - document.addEventListener("pointerdown", (e) => { + if (this.#onDocPointerDown) { + document.removeEventListener("pointerdown", this.#onDocPointerDown); + } + this.#onDocPointerDown = (e: PointerEvent) => { if (!menu.classList.contains("open")) return; const path = e.composedPath(); if (path.includes(menu) || path.includes(trigger)) return; this.#saveCreateFormState(menu); menu.classList.remove("open"); - }); + }; + document.addEventListener("pointerdown", this.#onDocPointerDown); // Prevent clicks inside menu from closing it menu.addEventListener("pointerdown", (e) => e.stopPropagation()); diff --git a/shared/local-first/runtime.ts b/shared/local-first/runtime.ts index db413783..88750a4b 100644 --- a/shared/local-first/runtime.ts +++ b/shared/local-first/runtime.ts @@ -54,6 +54,10 @@ export class RSpaceOfflineRuntime { #moduleScopes = new Map(); /** Lazy WebSocket connections per space slug (for cross-space subscriptions). */ #spaceConnections = new Map(); + /** Max idle space connections to keep alive (LRU eviction beyond this). */ + static readonly MAX_SPACE_CONNECTIONS = 3; + /** Access order for LRU eviction of space connections. */ + #spaceAccessOrder: string[] = []; constructor(space: string) { this.#activeSpace = space; @@ -322,10 +326,14 @@ export class RSpaceOfflineRuntime { this.#activeSpace = newSpace; + // Update LRU access order + this.#touchSpaceAccess(newSpace); + // Re-use existing connection if we've visited this space before const existing = this.#spaceConnections.get(newSpace); if (existing) { this.#sync = existing; + this.#evictStaleSpaces(); return; } @@ -337,6 +345,9 @@ export class RSpaceOfflineRuntime { this.#sync = newSync; this.#spaceConnections.set(newSpace, newSync); + // Evict old connections beyond the cap + this.#evictStaleSpaces(); + // Connect lazily — will connect when first doc is subscribed const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${newSpace}`; @@ -443,6 +454,32 @@ export class RSpaceOfflineRuntime { // ── Private ── + /** Move space to end of LRU access order. */ + #touchSpaceAccess(space: string): void { + this.#spaceAccessOrder = this.#spaceAccessOrder.filter(s => s !== space); + this.#spaceAccessOrder.push(space); + } + + /** Evict oldest idle space connections beyond the cap. */ + #evictStaleSpaces(): void { + while (this.#spaceConnections.size > RSpaceOfflineRuntime.MAX_SPACE_CONNECTIONS) { + // Find the oldest space that isn't the active one + const oldest = this.#spaceAccessOrder.find( + s => s !== this.#activeSpace && this.#spaceConnections.has(s) + ); + if (!oldest) break; + + const sync = this.#spaceConnections.get(oldest); + if (sync) { + sync.flush().catch(() => {}); + sync.disconnect(); + } + this.#spaceConnections.delete(oldest); + this.#spaceAccessOrder = this.#spaceAccessOrder.filter(s => s !== oldest); + console.debug(`[OfflineRuntime] Evicted idle space connection: ${oldest}`); + } + } + async #runStorageHousekeeping(): Promise { try { // Request persistent storage (browser may grant silently) diff --git a/shared/local-first/sync.ts b/shared/local-first/sync.ts index 515f774b..7677613c 100644 --- a/shared/local-first/sync.ts +++ b/shared/local-first/sync.ts @@ -294,6 +294,17 @@ export class DocSyncManager { for (const id of docIds) { if (this.#subscribedDocs.delete(id)) { removed.push(id); + + // Clean up per-doc state to free memory + this.#syncStates.delete(id); + this.#changeListeners.delete(id); + this.#awarenessListeners.delete(id); + + const timer = this.#saveTimers.get(id); + if (timer) { + clearTimeout(timer); + this.#saveTimers.delete(id); + } } } diff --git a/vite.config.ts b/vite.config.ts index 00a284d4..b202254e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,22 +2,33 @@ import { resolve } from "node:path"; import { defineConfig } from "vite"; import wasm from "vite-plugin-wasm"; +// Path to Automerge shim that re-exports from window.__automerge (loaded by shell-offline) +const AUTOMERGE_SHIM = resolve(__dirname, 'shared/automerge-shim.ts'); + // WASM-aware build wrapper — ensures every sub-build can handle Automerge WASM imports +// By default, aliases @automerge/automerge to a global shim (loaded by shell-offline) +// to avoid re-bundling ~2.5MB WASM per module. Set _bundleAutomerge = true to bundle +// the real Automerge (only needed for shell-offline and service worker builds). async function wasmBuild(config: any) { const { build } = await import("vite"); + const bundleAutomerge = config._bundleAutomerge ?? false; + const { _bundleAutomerge, ...restConfig } = config; + return build({ - ...config, - plugins: [...(config.plugins || []), wasm()], + ...restConfig, + plugins: [...(restConfig.plugins || []), wasm()], resolve: { - ...config.resolve, + ...restConfig.resolve, alias: { - ...(config.resolve?.alias || {}), - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), + ...(restConfig.resolve?.alias || {}), + '@automerge/automerge': bundleAutomerge + ? resolve(__dirname, 'node_modules/@automerge/automerge') + : AUTOMERGE_SHIM, }, }, build: { target: "esnext", - ...config.build, + ...restConfig.build, }, }); } @@ -55,6 +66,7 @@ export default defineConfig({ // Build shell.ts as a standalone JS bundle (needs wasm() for Automerge via runtime) await wasmBuild({ + _bundleAutomerge: true, configFile: false, root: resolve(__dirname, "website"), plugins: [wasm()], @@ -62,7 +74,6 @@ export default defineConfig({ alias: { "@lib": resolve(__dirname, "./lib"), "@shared": resolve(__dirname, "./shared"), - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), }, }, build: { @@ -358,18 +369,11 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rchoices/choices.css"), ); - // Build crowdsurf module component (with Automerge WASM for local-first client) + // Build crowdsurf module component (uses Automerge shim from shell-offline) await wasmBuild({ configFile: false, root: resolve(__dirname, "modules/crowdsurf/components"), - plugins: [wasm()], - resolve: { - alias: { - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), - }, - }, build: { - target: "esnext", emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/crowdsurf"), lib: { @@ -446,15 +450,10 @@ export default defineConfig({ await wasmBuild({ configFile: false, root: resolve(__dirname, "modules/rflows/components"), - plugins: [wasm()], resolve: { - alias: { - ...flowsAlias, - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), - }, + alias: flowsAlias, }, build: { - target: "esnext", emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rflows"), lib: { @@ -635,18 +634,11 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rvote/vote.css"), ); - // Build notes module component (with Automerge WASM support) + // Build notes module component (uses Automerge shim from shell-offline) await wasmBuild({ configFile: false, root: resolve(__dirname, "modules/rnotes/components"), - plugins: [wasm()], - resolve: { - alias: { - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), - }, - }, build: { - target: "esnext", emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rnotes"), lib: { @@ -950,18 +942,11 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rsocials/socials.css"), ); - // Build campaign planner component (with Automerge WASM support via local-first-client) + // Build campaign planner component (uses Automerge shim from shell-offline) await wasmBuild({ configFile: false, root: resolve(__dirname, "modules/rsocials/components"), - plugins: [wasm()], - resolve: { - alias: { - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), - }, - }, build: { - target: "esnext", emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rsocials"), lib: { diff --git a/website/shell-offline.ts b/website/shell-offline.ts index e05ff0cb..7c1d0109 100644 --- a/website/shell-offline.ts +++ b/website/shell-offline.ts @@ -5,11 +5,15 @@ * shell bundle stays small and doesn't block first paint. */ +import * as Automerge from '@automerge/automerge'; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RSpaceOfflineRuntime } from "../shared/local-first/runtime"; import { CommunitySync } from "../lib/community-sync"; import { OfflineStore } from "../lib/offline-store"; +// Expose Automerge globally so module builds can externalize it (~2.5MB savings per module) +(window as any).__automerge = Automerge; + // Define the history panel component (depends on Automerge) RStackHistoryPanel.define();