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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 22:26:24 -04:00
parent 76df746e15
commit 53c757e68e
44 changed files with 490 additions and 66 deletions

View File

@ -41,6 +41,7 @@ export class CommentPinManager {
#members: SpaceMember[] | null = null; #members: SpaceMember[] | null = null;
#mentionDropdown: HTMLElement | null = null; #mentionDropdown: HTMLElement | null = null;
#notes: RNoteItem[] | null = null; #notes: RNoteItem[] | null = null;
#onDocPointerDown: (e: PointerEvent) => void = () => {};
constructor( constructor(
container: HTMLElement, container: HTMLElement,
@ -87,13 +88,14 @@ export class CommentPinManager {
}); });
// Close popover on outside click // Close popover on outside click
document.addEventListener("pointerdown", (e) => { this.#onDocPointerDown = (e: PointerEvent) => {
if (this.#popover.style.display === "none") return; if (this.#popover.style.display === "none") return;
if (this.#popover.contains(e.target as Node)) return; if (this.#popover.contains(e.target as Node)) return;
// Don't close if clicking a pin marker // Don't close if clicking a pin marker
if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return; if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return;
this.closePopover(); this.closePopover();
}); };
document.addEventListener("pointerdown", this.#onDocPointerDown);
} }
// ── Camera ── // ── Camera ──
@ -973,6 +975,7 @@ export class CommentPinManager {
} }
destroy() { destroy() {
document.removeEventListener("pointerdown", this.#onDocPointerDown);
this.#pinLayer.remove(); this.#pinLayer.remove();
this.#popover.remove(); this.#popover.remove();
} }

View File

@ -299,6 +299,8 @@ export class FolkCommitmentPool extends FolkShape {
if (this.#animFrame) cancelAnimationFrame(this.#animFrame); if (this.#animFrame) cancelAnimationFrame(this.#animFrame);
this.#animFrame = 0; this.#animFrame = 0;
this.#removeGhost(); this.#removeGhost();
document.removeEventListener("pointermove", this.#onDocPointerMove);
document.removeEventListener("pointerup", this.#onDocPointerUp);
} }
// ── Data fetching ── // ── Data fetching ──

View File

@ -460,6 +460,12 @@ export class FolkDesignAgent extends FolkShape {
this.#setState("idle"); this.#setState("idle");
this.#addStep("!", "error", "Stopped by user"); this.#addStep("!", "error", "Stopped by user");
} }
override disconnectedCallback() {
super.disconnectedCallback?.();
this.#abortController?.abort();
this.#abortController = null;
}
} }
if (!customElements.get(FolkDesignAgent.tagName)) { if (!customElements.get(FolkDesignAgent.tagName)) {

View File

@ -595,6 +595,7 @@ export class FolkDrawfast extends FolkShape {
#resultArea: HTMLElement | null = null; #resultArea: HTMLElement | null = null;
#canvasArea: HTMLElement | null = null; #canvasArea: HTMLElement | null = null;
#gestureEnabled = true; #gestureEnabled = true;
#resizeObserver: ResizeObserver | null = null;
get strokes() { get strokes() {
return this.#strokes; return this.#strokes;
@ -832,11 +833,11 @@ export class FolkDrawfast extends FolkShape {
}); });
// Watch for resize // Watch for resize
const ro = new ResizeObserver(() => { this.#resizeObserver = new ResizeObserver(() => {
this.#resizeCanvas(canvasArea); this.#resizeCanvas(canvasArea);
this.#redraw(); this.#redraw();
}); });
ro.observe(canvasArea); this.#resizeObserver.observe(canvasArea);
return root; return root;
} }
@ -1133,6 +1134,12 @@ export class FolkDrawfast extends FolkShape {
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
override disconnectedCallback() {
super.disconnectedCallback?.();
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
static override fromData(data: Record<string, any>): FolkDrawfast { static override fromData(data: Record<string, any>): FolkDrawfast {
const shape = FolkShape.fromData(data) as FolkDrawfast; const shape = FolkShape.fromData(data) as FolkDrawfast;
return shape; return shape;

View File

@ -351,6 +351,7 @@ export class FolkMakereal extends FolkShape {
#promptInput: HTMLInputElement | null = null; #promptInput: HTMLInputElement | null = null;
#generateBtn: HTMLButtonElement | null = null; #generateBtn: HTMLButtonElement | null = null;
#resultArea: HTMLElement | null = null; #resultArea: HTMLElement | null = null;
#resizeObserver: ResizeObserver | null = null;
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
@ -524,11 +525,11 @@ export class FolkMakereal extends FolkShape {
this.#redraw(); this.#redraw();
}); });
const ro = new ResizeObserver(() => { this.#resizeObserver = new ResizeObserver(() => {
this.#resizeCanvas(canvasArea); this.#resizeCanvas(canvasArea);
this.#redraw(); this.#redraw();
}); });
ro.observe(canvasArea); this.#resizeObserver.observe(canvasArea);
return root; return root;
} }
@ -738,6 +739,12 @@ export class FolkMakereal extends FolkShape {
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
override disconnectedCallback() {
super.disconnectedCallback?.();
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
static override fromData(data: Record<string, any>): FolkMakereal { static override fromData(data: Record<string, any>): FolkMakereal {
const shape = FolkShape.fromData(data) as FolkMakereal; const shape = FolkShape.fromData(data) as FolkMakereal;
return shape; return shape;

View File

@ -434,6 +434,7 @@ interface MapLibreMap {
getZoom(): number; getZoom(): number;
addControl(control: unknown, position?: string): void; addControl(control: unknown, position?: string): void;
on(event: string, handler: (e: MapLibreEvent) => void): void; on(event: string, handler: (e: MapLibreEvent) => void): void;
remove(): void;
} }
interface MapLibreEvent { interface MapLibreEvent {
@ -818,6 +819,8 @@ export class FolkMap extends FolkShape {
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback?.(); super.disconnectedCallback?.();
this.#leaveRoom(); this.#leaveRoom();
this.#map?.remove();
this.#map = null;
} }
// ── MapLibre loading ── // ── MapLibre loading ──

View File

@ -154,6 +154,7 @@ export class FolkPiano extends FolkShape {
#errorEl: HTMLElement | null = null; #errorEl: HTMLElement | null = null;
#minimizedEl: HTMLElement | null = null; #minimizedEl: HTMLElement | null = null;
#containerEl: HTMLElement | null = null; #containerEl: HTMLElement | null = null;
#onWindowError: ((e: ErrorEvent) => void) | null = null;
get isMinimized() { get isMinimized() {
return this.#isMinimized; return this.#isMinimized;
@ -249,11 +250,12 @@ export class FolkPiano extends FolkShape {
}); });
// Suppress Chrome Music Lab console errors // Suppress Chrome Music Lab console errors
window.addEventListener("error", (e) => { this.#onWindowError = (e: ErrorEvent) => {
if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) { if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) {
e.preventDefault(); e.preventDefault();
} }
}); };
window.addEventListener("error", this.#onWindowError);
return root; return root;
} }
@ -281,6 +283,14 @@ export class FolkPiano extends FolkShape {
this.#iframe.src = PIANO_URL; 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<string, any>): FolkPiano { static override fromData(data: Record<string, any>): FolkPiano {
const shape = FolkShape.fromData(data) as FolkPiano; const shape = FolkShape.fromData(data) as FolkPiano;
if (data.isMinimized != null) shape.isMinimized = data.isMinimized; if (data.isMinimized != null) shape.isMinimized = data.isMinimized;

View File

@ -237,6 +237,7 @@ export class FolkSplat extends FolkShape {
#viewerCanvas: HTMLCanvasElement | null = null; #viewerCanvas: HTMLCanvasElement | null = null;
#gallerySplats: any[] = []; #gallerySplats: any[] = [];
#urlInput: HTMLInputElement | null = null; #urlInput: HTMLInputElement | null = null;
#rafId: number | null = null;
get splatUrl() { get splatUrl() {
return this.#splatUrl; return this.#splatUrl;
@ -403,7 +404,7 @@ export class FolkSplat extends FolkShape {
if (!this.#viewer) return; if (!this.#viewer) return;
(viewer as any).update(); (viewer as any).update();
(viewer as any).render(); (viewer as any).render();
requestAnimationFrame(animate); this.#rafId = requestAnimationFrame(animate);
}; };
animate(); animate();
} catch (err) { } 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<string, any>): void { override applyData(data: Record<string, any>): void {
super.applyData(data); super.applyData(data);
if ("splatUrl" in data && this.splatUrl !== data.splatUrl) { if ("splatUrl" in data && this.splatUrl !== data.splatUrl) {

View File

@ -73,6 +73,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
// Multiplayer // Multiplayer
private lfClient: CrowdSurfLocalFirstClient | null = null; private lfClient: CrowdSurfLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Expiry timer // Expiry timer
@ -108,6 +109,11 @@ class FolkCrowdSurfDashboard extends HTMLElement {
this._lfcUnsub = null; this._lfcUnsub = null;
this.lfClient?.disconnect(); this.lfClient?.disconnect();
if (this._expiryTimer !== null) clearInterval(this._expiryTimer); 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -158,6 +164,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
try { try {
const docId = crowdsurfDocId(this.space) as DocumentId; const docId = crowdsurfDocId(this.space) as DocumentId;
await runtime.subscribe(docId, crowdsurfSchema); await runtime.subscribe(docId, crowdsurfSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -73,6 +73,7 @@ class FolkBnbView extends HTMLElement {
#tour: LightTourEngine | null = null; #tour: LightTourEngine | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
connectedCallback() { connectedCallback() {
this.#space = this.getAttribute('space') || 'demo'; this.#space = this.getAttribute('space') || 'demo';
@ -88,6 +89,11 @@ class FolkBnbView extends HTMLElement {
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -103,6 +109,7 @@ class FolkBnbView extends HTMLElement {
try { try {
const docId = bnbDocId(this.#space) as DocumentId; const docId = bnbDocId(this.#space) as DocumentId;
await runtime.subscribe(docId, bnbSchema); await runtime.subscribe(docId, bnbSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -40,6 +40,9 @@ export class FolkBookReader extends HTMLElement {
private _error: string | null = null; private _error: string | null = null;
private _flipBook: any = null; private _flipBook: any = null;
private _db: IDBDatabase | null = 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() { static get observedAttributes() {
return ["pdf-url", "book-id", "title", "author"]; 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)); localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
this._flipBook?.destroy(); this._flipBook?.destroy();
this._db?.close(); 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 ── // ── IndexedDB cache ──
@ -340,17 +346,20 @@ export class FolkBookReader extends HTMLElement {
}); });
// Keyboard nav // 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(); if (e.key === "ArrowLeft") this._flipBook?.flipPrev();
else if (e.key === "ArrowRight") this._flipBook?.flipNext(); else if (e.key === "ArrowRight") this._flipBook?.flipNext();
}); };
document.addEventListener("keydown", this._keyHandler);
// Resize handler // Resize handler
let resizeTimer: number; if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler);
window.addEventListener("resize", () => { this._resizeHandler = () => {
clearTimeout(resizeTimer); if (this._resizeTimer !== null) clearTimeout(this._resizeTimer);
resizeTimer = window.setTimeout(() => this.renderReader(), 250); this._resizeTimer = window.setTimeout(() => this.renderReader(), 250);
}); };
window.addEventListener("resize", this._resizeHandler);
} }
private updatePageCounter() { private updatePageCounter() {

View File

@ -38,6 +38,7 @@ export class FolkBookShelf extends HTMLElement {
private _searchTerm = ""; private _searchTerm = "";
private _selectedTag: string | null = null; private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -90,6 +91,11 @@ export class FolkBookShelf extends HTMLElement {
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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() { private async subscribeOffline() {
@ -99,6 +105,7 @@ export class FolkBookShelf extends HTMLElement {
try { try {
const docId = booksCatalogDocId(this._spaceSlug) as DocumentId; const docId = booksCatalogDocId(this._spaceSlug) as DocumentId;
const doc = await runtime.subscribe(docId, booksCatalogSchema); const doc = await runtime.subscribe(docId, booksCatalogSchema);
this._subscribedDocIds.push(docId);
this.renderFromDoc(doc); this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this._offlineUnsub = runtime.onChange(docId, (updated: any) => {

View File

@ -127,6 +127,7 @@ class FolkCalendarView extends HTMLElement {
private filteredSources = new Set<string>(); private filteredSources = new Set<string>();
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Spatio-temporal state // Spatio-temporal state
@ -219,6 +220,11 @@ class FolkCalendarView extends HTMLElement {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
this._stopPresence?.(); this._stopPresence?.();
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
if (this.boundKeyHandler) { if (this.boundKeyHandler) {
document.removeEventListener("keydown", this.boundKeyHandler); document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null; this.boundKeyHandler = null;
@ -247,6 +253,7 @@ class FolkCalendarView extends HTMLElement {
try { try {
const docId = calendarDocId(this.space) as DocumentId; const docId = calendarDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, calendarSchema); const doc = await runtime.subscribe(docId, calendarSchema);
this._subscribedDocIds.push(docId);
this.renderFromCalDoc(doc); this.renderFromCalDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this._offlineUnsub = runtime.onChange(docId, (updated: any) => {

View File

@ -38,6 +38,7 @@ class FolkCartShop extends HTMLElement {
private creatingPayment = false; private creatingPayment = false;
private creatingGroupBuy = false; private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = []; 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 _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart");
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
@ -100,6 +101,11 @@ class FolkCartShop extends HTMLElement {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.(); this._stopPresence?.();
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
} }
private async subscribeOffline() { private async subscribeOffline() {
@ -110,6 +116,7 @@ class FolkCartShop extends HTMLElement {
// Subscribe to catalog // Subscribe to catalog
const catDocId = catalogDocId(this.space) as DocumentId; const catDocId = catalogDocId(this.space) as DocumentId;
const catDoc = await runtime.subscribe(catDocId, catalogSchema); const catDoc = await runtime.subscribe(catDocId, catalogSchema);
this._subscribedDocIds.push(catDocId);
if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) { if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) {
this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({ this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({
id: item.id, title: item.title, description: '', id: item.id, title: item.title, description: '',
@ -135,6 +142,7 @@ class FolkCartShop extends HTMLElement {
// Subscribe to shopping cart index // Subscribe to shopping cart index
const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId; const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId;
const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema); const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema);
this._subscribedDocIds.push(indexDocId);
if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) { if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) {
this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c })); this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c }));
this.render(); this.render();

View File

@ -1116,6 +1116,7 @@ routes.post("/api/shopping-carts/:cartId/items", async (c) => {
detail: `Added ${productData.name || 'item'}`, detail: `Added ${productData.name || 'item'}`,
timestamp: now, timestamp: now,
}); });
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
}); });
reindexCart(space, cartId); reindexCart(space, cartId);
@ -1172,6 +1173,7 @@ routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
detail: `Removed ${itemName}`, detail: `Removed ${itemName}`,
timestamp: Date.now(), timestamp: Date.now(),
}); });
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
}); });
reindexCart(space, cartId); reindexCart(space, cartId);
@ -1212,6 +1214,7 @@ routes.post("/api/shopping-carts/:cartId/contribute", async (c) => {
detail: `Contributed $${amount.toFixed(2)}`, detail: `Contributed $${amount.toFixed(2)}`,
timestamp: now, timestamp: now,
}); });
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
}); });
reindexCart(space, cartId); 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'}`, detail: `Paid $${contribAmount.toFixed(2)} via ${updated!.payment.paymentMethod || 'wallet'}`,
timestamp: contribNow, timestamp: contribNow,
}); });
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
}); });
reindexCart(space, linkedCartId); reindexCart(space, linkedCartId);
} }

View File

@ -57,6 +57,7 @@ class FolkChoicesDashboard extends HTMLElement {
/* Multiplayer state */ /* Multiplayer state */
private lfClient: ChoicesLocalFirstClient | null = null; private lfClient: ChoicesLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private sessions: ChoiceSession[] = []; private sessions: ChoiceSession[] = [];
private activeSessionId: string | null = null; private activeSessionId: string | null = null;
@ -119,6 +120,11 @@ class FolkChoicesDashboard extends HTMLElement {
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this.lfClient?.disconnect(); 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() { private async initMultiplayer() {
@ -157,6 +163,7 @@ class FolkChoicesDashboard extends HTMLElement {
try { try {
const docId = choicesDocId(this.space) as DocumentId; const docId = choicesDocId(this.space) as DocumentId;
await runtime.subscribe(docId, choicesSchema); await runtime.subscribe(docId, choicesSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -186,6 +186,7 @@ class FolkDocsApp extends HTMLElement {
private syncConnected = false; private syncConnected = false;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _offlineNotebookUnsubs: (() => void)[] = []; private _offlineNotebookUnsubs: (() => void)[] = [];
private _subscribedDocIds: string[] = [];
// ── Presence indicators ── // ── Presence indicators ──
private _presencePeers: Map<string, { private _presencePeers: Map<string, {
@ -613,6 +614,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this._offlineUnsub = null; this._offlineUnsub = null;
for (const unsub of this._offlineNotebookUnsubs) unsub(); for (const unsub of this._offlineNotebookUnsubs) unsub();
this._offlineNotebookUnsubs = []; 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) ── // ── Sync (via shared runtime) ──
@ -629,6 +635,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
try { try {
const docId = this.subscribedDocId as DocumentId; const docId = this.subscribedDocId as DocumentId;
const doc = await runtime.subscribe(docId, notebookSchema); const doc = await runtime.subscribe(docId, notebookSchema);
this._subscribedDocIds.push(docId);
this.doc = doc; this.doc = doc;
this.renderFromDoc(); this.renderFromDoc();

View File

@ -488,6 +488,7 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
if (!body.text?.trim()) return c.json({ error: 'text required' }, 400); if (!body.text?.trim()) return c.json({ error: 'text required' }, 400);
const msgId = crypto.randomUUID(); const msgId = crypto.randomUUID();
const MAX_CHAT_MESSAGES = 200;
ss().changeDoc<ExchangeTradesDoc>(exchangeTradesDocId(space), 'trade chat message', (d) => { ss().changeDoc<ExchangeTradesDoc>(exchangeTradesDocId(space), 'trade chat message', (d) => {
if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any; if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any;
(d.trades[id].chatMessages as any[]).push({ (d.trades[id].chatMessages as any[]).push({
@ -497,6 +498,11 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
text: body.text.trim(), text: body.text.trim(),
timestamp: Date.now(), 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<ExchangeTradesDoc>(exchangeTradesDocId(space))!; const updated = ss().getDoc<ExchangeTradesDoc>(exchangeTradesDocId(space))!;

View File

@ -90,6 +90,7 @@ class FolkFlowsApp extends HTMLElement {
private loading = false; private loading = false;
private error = ""; private error = "";
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
// Canvas state // Canvas state
private canvasZoom = 1; private canvasZoom = 1;
@ -201,6 +202,10 @@ class FolkFlowsApp extends HTMLElement {
// Tour engine // Tour engine
private _tour!: TourEngine; private _tour!: TourEngine;
// Cleanup handles
private _themeChangeHandler: (() => void) | null = null;
private _mutationObserver: MutationObserver | null = null;
private static readonly TOUR_STEPS = [ 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-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 }, { 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 // Mirror document theme to host for shadow DOM CSS selectors
this._syncTheme(); this._syncTheme();
document.addEventListener("theme-change", () => this._syncTheme()); this._themeChangeHandler = () => this._syncTheme();
new MutationObserver(() => this._syncTheme()) document.addEventListener("theme-change", this._themeChangeHandler);
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); this._mutationObserver = new MutationObserver(() => this._syncTheme());
this._mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// Read view attribute, default to canvas (detail) view // Read view attribute, default to canvas (detail) view
const viewAttr = this.getAttribute("view"); const viewAttr = this.getAttribute("view");
@ -364,6 +370,11 @@ class FolkFlowsApp extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this._stopPresence?.(); this._stopPresence?.();
@ -372,6 +383,12 @@ class FolkFlowsApp extends HTMLElement {
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; } if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; }
this.localFirstClient?.disconnect(); this.localFirstClient?.disconnect();
if (this._themeChangeHandler) {
document.removeEventListener("theme-change", this._themeChangeHandler);
this._themeChangeHandler = null;
}
this._mutationObserver?.disconnect();
this._mutationObserver = null;
} }
// ─── Auto-save (debounced) ────────────────────────────── // ─── Auto-save (debounced) ──────────────────────────────
@ -533,6 +550,7 @@ class FolkFlowsApp extends HTMLElement {
try { try {
const docId = flowsDocId(this.space) as DocumentId; const docId = flowsDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, flowsSchema); const doc = await runtime.subscribe(docId, flowsSchema);
this._subscribedDocIds.push(docId);
// Render cached flow associations immediately // Render cached flow associations immediately
this.renderFlowsFromDoc(doc); this.renderFlowsFromDoc(doc);

View File

@ -21,6 +21,7 @@ class FolkForumDashboard extends HTMLElement {
private pollTimer: number | null = null; private pollTimer: number | null = null;
private space = ""; private space = "";
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum"); private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum");
private _tour!: TourEngine; private _tour!: TourEngine;
@ -73,6 +74,11 @@ class FolkForumDashboard extends HTMLElement {
if (this.pollTimer) clearInterval(this.pollTimer); if (this.pollTimer) clearInterval(this.pollTimer);
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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() { private async subscribeOffline() {
@ -83,6 +89,7 @@ class FolkForumDashboard extends HTMLElement {
// Forum uses a global doc (not space-scoped) // Forum uses a global doc (not space-scoped)
const docId = FORUM_DOC_ID as DocumentId; const docId = FORUM_DOC_ID as DocumentId;
const doc = await runtime.subscribe(docId, forumSchema); const doc = await runtime.subscribe(docId, forumSchema);
this._subscribedDocIds.push(docId);
if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) { if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) {
this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({ this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({

View File

@ -149,6 +149,7 @@ class FolkMapViewer extends HTMLElement {
private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps"); private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps");
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
// Chat + Local-first state // Chat + Local-first state
private lfClient: MapsLocalFirstClient | null = null; private lfClient: MapsLocalFirstClient | null = null;
@ -226,6 +227,11 @@ class FolkMapViewer extends HTMLElement {
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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; } if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; }
this.leaveRoom(); this.leaveRoom();
if (this._themeObserver) { if (this._themeObserver) {
@ -251,6 +257,7 @@ class FolkMapViewer extends HTMLElement {
try { try {
const docId = mapsDocId(this.space) as DocumentId; const docId = mapsDocId(this.space) as DocumentId;
await runtime.subscribe(docId, mapsSchema); await runtime.subscribe(docId, mapsSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -28,6 +28,7 @@ class FolkJitsiRoom extends HTMLElement {
private directorError = ""; private directorError = "";
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
constructor() { constructor() {
super(); super();
@ -52,6 +53,11 @@ class FolkJitsiRoom extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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(); this.dispose();
} }
@ -61,6 +67,7 @@ class FolkJitsiRoom extends HTMLElement {
try { try {
const docId = meetsDocId(this.space) as DocumentId; const docId = meetsDocId(this.space) as DocumentId;
await runtime.subscribe(docId, meetsSchema); await runtime.subscribe(docId, meetsSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -108,6 +108,7 @@ class FolkCrmView extends HTMLElement {
private graphSelectedId: string | null = null; private graphSelectedId: string | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
// Guided tour // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
@ -175,6 +176,11 @@ class FolkCrmView extends HTMLElement {
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
document.removeEventListener("rapp-tab-change", this._onTabChange); document.removeEventListener("rapp-tab-change", this._onTabChange);
this._stopPresence?.(); 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -190,6 +196,7 @@ class FolkCrmView extends HTMLElement {
try { try {
const docId = networkDocId(this.space) as DocumentId; const docId = networkDocId(this.space) as DocumentId;
await runtime.subscribe(docId, networkSchema); await runtime.subscribe(docId, networkSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -169,6 +169,7 @@ class FolkGraphViewer extends HTMLElement {
private _textSpriteCache = new Map<string, any>(); private _textSpriteCache = new Map<string, any>();
private _badgeSpriteCache = new Map<string, any>(); private _badgeSpriteCache = new Map<string, any>();
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
// Local-first client for layer config persistence // Local-first client for layer config persistence
private _lfClient: NetworkLocalFirstClient | null = null; private _lfClient: NetworkLocalFirstClient | null = null;
@ -237,6 +238,11 @@ class FolkGraphViewer extends HTMLElement {
this.graph._destructor?.(); this.graph._destructor?.();
this.graph = null; 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() { private async subscribeCollabOverlay() {
@ -245,6 +251,7 @@ class FolkGraphViewer extends HTMLElement {
try { try {
const docId = networkDocId(this.space) as DocumentId; const docId = networkDocId(this.space) as DocumentId;
await runtime.subscribe(docId, networkSchema); await runtime.subscribe(docId, networkSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -54,6 +54,7 @@ class FolkPhotoGallery extends HTMLElement {
private _tour!: TourEngine; private _tour!: TourEngine;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos"); private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos");
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false }, { 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() { disconnectedCallback() {
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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(); this._history.destroy();
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
} }
@ -108,6 +114,7 @@ class FolkPhotoGallery extends HTMLElement {
try { try {
const docId = photosDocId(this.space) as DocumentId; const docId = photosDocId(this.space) as DocumentId;
await runtime.subscribe(docId, photosSchema); await runtime.subscribe(docId, photosSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -25,6 +25,7 @@ export class FolkPubsFlipbook extends HTMLElement {
private _flipBook: any = null; private _flipBook: any = null;
private _keyHandler: ((e: KeyboardEvent) => void) | null = null; private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
private _resizeTimer: ReturnType<typeof setTimeout> | null = null; private _resizeTimer: ReturnType<typeof setTimeout> | null = null;
private _resizeHandler: (() => void) | null = null;
static get observedAttributes() { static get observedAttributes() {
return ["pdf-url"]; return ["pdf-url"];
@ -46,8 +47,9 @@ export class FolkPubsFlipbook extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this._flipBook?.destroy(); this._flipBook?.destroy();
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler); if (this._keyHandler) { document.removeEventListener("keydown", this._keyHandler); this._keyHandler = null; }
if (this._resizeTimer) clearTimeout(this._resizeTimer); if (this._resizeTimer) { clearTimeout(this._resizeTimer); this._resizeTimer = null; }
if (this._resizeHandler) { window.removeEventListener("resize", this._resizeHandler); this._resizeHandler = null; }
} }
private async loadPDF() { private async loadPDF() {
@ -294,10 +296,12 @@ export class FolkPubsFlipbook extends HTMLElement {
}; };
document.addEventListener("keydown", this._keyHandler); document.addEventListener("keydown", this._keyHandler);
window.addEventListener("resize", () => { if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler);
this._resizeHandler = () => {
if (this._resizeTimer) clearTimeout(this._resizeTimer); if (this._resizeTimer) clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => this.renderReader(), 250); this._resizeTimer = setTimeout(() => this.renderReader(), 250);
}); };
window.addEventListener("resize", this._resizeHandler);
} }
private renderFallback() { private renderFallback() {

View File

@ -91,6 +91,7 @@ class FolkScheduleApp extends HTMLElement {
private loading = false; private loading = false;
private runningJobId: string | null = null; private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -148,6 +149,11 @@ class FolkScheduleApp extends HTMLElement {
this._offlineUnsub = null; this._offlineUnsub = null;
} }
this._stopPresence?.(); this._stopPresence?.();
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
} }
private async subscribeOffline() { private async subscribeOffline() {
@ -157,6 +163,7 @@ class FolkScheduleApp extends HTMLElement {
try { try {
const docId = scheduleDocId(this.space) as DocumentId; const docId = scheduleDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, scheduleSchema); const doc = await runtime.subscribe(docId, scheduleSchema);
this._subscribedDocIds.push(docId);
if (doc) this.renderFromDoc(doc as ScheduleDoc); if (doc) this.renderFromDoc(doc as ScheduleDoc);
this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => { this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => {

View File

@ -15,6 +15,7 @@ export class FolkCampaignManager extends HTMLElement {
private _space = 'demo'; private _space = 'demo';
private _campaigns: Campaign[] = []; private _campaigns: Campaign[] = [];
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
// AI generation state // AI generation state
private _generatedCampaign: Campaign | null = null; private _generatedCampaign: Campaign | null = null;
@ -58,6 +59,11 @@ export class FolkCampaignManager extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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) { attributeChangedCallback(name: string, _old: string, val: string) {
@ -71,6 +77,7 @@ export class FolkCampaignManager extends HTMLElement {
try { try {
const docId = socialsDocId(this._space) as DocumentId; const docId = socialsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, socialsSchema); const doc = await runtime.subscribe(docId, socialsSchema);
this._subscribedDocIds.push(docId);
this.renderFromDoc(doc); this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this._offlineUnsub = runtime.onChange(docId, (updated: any) => {

View File

@ -30,6 +30,7 @@ export class FolkThreadBuilder extends HTMLElement {
private _tweetImages: Record<string, string> = {}; private _tweetImages: Record<string, string> = {};
private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null; private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _offlineReady: Promise<void> | null = null; private _offlineReady: Promise<void> | null = null;
private _tweetImageUploadIdx: string | null = null; private _tweetImageUploadIdx: string | null = null;
private _linkPreviewCache: Map<string, { title: string; description: string; image: string | null; domain: string } | null> = new Map(); private _linkPreviewCache: Map<string, { title: string; description: string; image: string | null; domain: string } | null> = new Map();
@ -72,6 +73,11 @@ export class FolkThreadBuilder extends HTMLElement {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); 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) { attributeChangedCallback(name: string, _old: string, val: string) {
@ -111,6 +117,7 @@ export class FolkThreadBuilder extends HTMLElement {
try { try {
const docId = socialsDocId(this._space) as DocumentId; const docId = socialsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, socialsSchema); const doc = await runtime.subscribe(docId, socialsSchema);
this._subscribedDocIds.push(docId);
if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) { if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) {
// Deep-clone to get a plain object (not an Automerge proxy) // Deep-clone to get a plain object (not an Automerge proxy)

View File

@ -27,6 +27,7 @@ export class FolkThreadGallery extends HTMLElement {
private _threads: ThreadData[] = []; private _threads: ThreadData[] = [];
private _draftPosts: DraftPostCard[] = []; private _draftPosts: DraftPostCard[] = [];
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _isDemoFallback = false; private _isDemoFallback = false;
static get observedAttributes() { return ['space']; } static get observedAttributes() { return ['space']; }
@ -41,6 +42,11 @@ export class FolkThreadGallery extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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) { attributeChangedCallback(name: string, _old: string, val: string) {
@ -62,6 +68,7 @@ export class FolkThreadGallery extends HTMLElement {
try { try {
const docId = socialsDocId(this._space) as DocumentId; const docId = socialsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, socialsSchema); const doc = await runtime.subscribe(docId, socialsSchema);
this._subscribedDocIds.push(docId);
this.renderFromDoc(doc); this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this._offlineUnsub = runtime.onChange(docId, (updated: any) => {

View File

@ -54,6 +54,7 @@ export class FolkSplatViewer extends HTMLElement {
private _inlineViewer = false; private _inlineViewer = false;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _generatedUrl = ""; private _generatedUrl = "";
private _generatedTitle = ""; private _generatedTitle = "";
private _savedSlug = ""; private _savedSlug = "";
@ -100,6 +101,7 @@ export class FolkSplatViewer extends HTMLElement {
try { try {
const docId = splatScenesDocId(this._spaceSlug) as DocumentId; const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
const doc = await runtime.subscribe(docId, splatScenesSchema); const doc = await runtime.subscribe(docId, splatScenesSchema);
this._subscribedDocIds.push(docId);
this.renderFromDoc(doc); this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
@ -155,6 +157,11 @@ export class FolkSplatViewer extends HTMLElement {
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; 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) { if (this._viewer) {
try { this._viewer.dispose(); } catch {} try { this._viewer.dispose(); } catch {}
this._viewer = null; this._viewer = null;

View File

@ -246,6 +246,7 @@ class FolkSwagDesigner extends HTMLElement {
private lfClient: SwagLocalFirstClient | null = null; private lfClient: SwagLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private sharedDesigns: SwagDesign[] = []; private sharedDesigns: SwagDesign[] = [];
private _tour!: TourEngine; private _tour!: TourEngine;
@ -296,6 +297,11 @@ class FolkSwagDesigner extends HTMLElement {
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this.lfClient?.disconnect(); 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -330,6 +336,7 @@ class FolkSwagDesigner extends HTMLElement {
try { try {
const docId = swagDocId(this.space) as DocumentId; const docId = swagDocId(this.space) as DocumentId;
await runtime.subscribe(docId, swagSchema); await runtime.subscribe(docId, swagSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -328,6 +328,8 @@ class FolkTimebankApp extends HTMLElement {
private _theme: 'dark' | 'light' = 'dark'; private _theme: 'dark' | 'light' = 'dark';
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _resizeObserver: ResizeObserver | null = null;
constructor() { constructor() {
super(); super();
@ -368,6 +370,7 @@ class FolkTimebankApp extends HTMLElement {
try { try {
const docId = commitmentsDocId(this.space) as DocumentId; const docId = commitmentsDocId(this.space) as DocumentId;
await runtime.subscribe(docId, commitmentsSchema); await runtime.subscribe(docId, commitmentsSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }
@ -399,6 +402,13 @@ class FolkTimebankApp extends HTMLElement {
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
if (this.animFrame) cancelAnimationFrame(this.animFrame); if (this.animFrame) cancelAnimationFrame(this.animFrame);
this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -806,8 +816,8 @@ class FolkTimebankApp extends HTMLElement {
}); });
// Resize — now targets pool panel // Resize — now targets pool panel
const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); }); this._resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); });
resizeObserver.observe(this); this._resizeObserver.observe(this);
this.resizePoolCanvas(); this.resizePoolCanvas();
this.poolFrame(); this.poolFrame();

View File

@ -39,6 +39,7 @@ class FolkVideoPlayer extends HTMLElement {
private expandedView: number | null = null; private expandedView: number | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false }, { 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); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -90,6 +98,7 @@ class FolkVideoPlayer extends HTMLElement {
try { try {
const docId = tubeDocId(this.space) as DocumentId; const docId = tubeDocId(this.space) as DocumentId;
await runtime.subscribe(docId, tubeSchema); await runtime.subscribe(docId, tubeSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -78,6 +78,7 @@ class FolkVnbView extends HTMLElement {
#tour: LightTourEngine | null = null; #tour: LightTourEngine | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
connectedCallback() { connectedCallback() {
this.#space = this.getAttribute('space') || 'demo'; this.#space = this.getAttribute('space') || 'demo';
@ -93,6 +94,11 @@ class FolkVnbView extends HTMLElement {
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
this._stopPresence?.(); this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub = null; 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -108,6 +114,7 @@ class FolkVnbView extends HTMLElement {
try { try {
const docId = vnbDocId(this.#space) as DocumentId; const docId = vnbDocId(this.#space) as DocumentId;
await runtime.subscribe(docId, vnbSchema); await runtime.subscribe(docId, vnbSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -226,6 +226,7 @@ class FolkWalletViewer extends HTMLElement {
// Multiplayer state // Multiplayer state
private lfClient: WalletLocalFirstClient | null = null; private lfClient: WalletLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private watchedAddresses: WatchedAddress[] = []; private watchedAddresses: WatchedAddress[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
@ -280,6 +281,12 @@ class FolkWalletViewer extends HTMLElement {
clearInterval(this.flowsPlayInterval); clearInterval(this.flowsPlayInterval);
this.flowsPlayInterval = null; 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) => { private _onViewRestored = (e: CustomEvent) => {
@ -341,6 +348,7 @@ class FolkWalletViewer extends HTMLElement {
try { try {
const docId = walletDocId(this.space) as DocumentId; const docId = walletDocId(this.space) as DocumentId;
await runtime.subscribe(docId, walletSchema); await runtime.subscribe(docId, walletSchema);
this._subscribedDocIds.push(docId);
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }

View File

@ -104,6 +104,10 @@ export interface SyncServerOptions {
onRelayBackup?: (docId: string, blob: Uint8Array) => void; onRelayBackup?: (docId: string, blob: Uint8Array) => void;
/** Called to load a relay blob for restore on subscribe */ /** Called to load a relay blob for restore on subscribe */
onRelayLoad?: (docId: string) => Promise<Uint8Array | null>; onRelayLoad?: (docId: string) => Promise<Uint8Array | null>;
/** Called before evicting a doc from memory (persist to disk) */
onDocEvict?: (docId: string, doc: Automerge.Doc<any>) => void;
/** Max docs to keep in memory; 0 = unlimited (default: 500) */
maxDocs?: number;
} }
// ============================================================================ // ============================================================================
@ -114,18 +118,25 @@ export class SyncServer {
#peers = new Map<string, Peer>(); #peers = new Map<string, Peer>();
#docs = new Map<string, Automerge.Doc<any>>(); #docs = new Map<string, Automerge.Doc<any>>();
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId> #docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
#docLastAccess = new Map<string, number>(); // docId → timestamp
#participantMode: boolean; #participantMode: boolean;
#relayOnlyDocs = new Set<string>(); // docIds forced to relay mode (encrypted spaces) #relayOnlyDocs = new Set<string>(); // docIds forced to relay mode (encrypted spaces)
#watchers: Array<{ prefix: string; cb: (docId: string, doc: Automerge.Doc<any>) => void }> = []; #watchers: Array<{ prefix: string; cb: (docId: string, doc: Automerge.Doc<any>) => void }> = [];
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void; #onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
#onRelayBackup?: (docId: string, blob: Uint8Array) => void; #onRelayBackup?: (docId: string, blob: Uint8Array) => void;
#onRelayLoad?: (docId: string) => Promise<Uint8Array | null>; #onRelayLoad?: (docId: string) => Promise<Uint8Array | null>;
/** Called before evicting a doc so it can be persisted to disk. */
#onDocEvict?: (docId: string, doc: Automerge.Doc<any>) => void;
/** Max docs to keep in memory (0 = unlimited). */
#maxDocs: number;
constructor(opts: SyncServerOptions = {}) { constructor(opts: SyncServerOptions = {}) {
this.#participantMode = opts.participantMode ?? true; this.#participantMode = opts.participantMode ?? true;
this.#onDocChange = opts.onDocChange; this.#onDocChange = opts.onDocChange;
this.#onRelayBackup = opts.onRelayBackup; this.#onRelayBackup = opts.onRelayBackup;
this.#onRelayLoad = opts.onRelayLoad; 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). * Get a server-side document (participant mode).
*/ */
getDoc<T>(docId: string): Automerge.Doc<T> | undefined { getDoc<T>(docId: string): Automerge.Doc<T> | undefined {
if (this.#docs.has(docId)) this.#touchDoc(docId);
return this.#docs.get(docId); return this.#docs.get(docId);
} }
@ -246,10 +258,12 @@ export class SyncServer {
*/ */
setDoc(docId: string, doc: Automerge.Doc<any>): void { setDoc(docId: string, doc: Automerge.Doc<any>): void {
this.#docs.set(docId, doc); this.#docs.set(docId, doc);
this.#touchDoc(docId);
this.#syncDocToAllPeers(docId); this.#syncDocToAllPeers(docId);
if (this.#onDocChange) { if (this.#onDocChange) {
this.#onDocChange(docId, doc); this.#onDocChange(docId, doc);
} }
this.#evictIfNeeded();
} }
/** /**
@ -261,6 +275,7 @@ export class SyncServer {
doc = Automerge.change(doc, message, fn as any); doc = Automerge.change(doc, message, fn as any);
this.#docs.set(docId, doc); this.#docs.set(docId, doc);
this.#touchDoc(docId);
this.#syncDocToAllPeers(docId); this.#syncDocToAllPeers(docId);
if (this.#onDocChange) { if (this.#onDocChange) {
@ -290,9 +305,15 @@ export class SyncServer {
/** /**
* Register a callback to fire when any doc whose ID contains `prefix` changes. * 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<any>) => void): void { registerWatcher(prefix: string, cb: (docId: string, doc: Automerge.Doc<any>) => void): () => void {
this.#watchers.push({ prefix, cb }); 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 // Create an empty doc if this is the first time we see this docId
doc = Automerge.init(); doc = Automerge.init();
this.#docs.set(docId, doc); this.#docs.set(docId, doc);
this.#evictIfNeeded();
} }
let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState(); let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState();
@ -382,6 +404,7 @@ export class SyncServer {
const changed = newDoc !== doc; const changed = newDoc !== doc;
this.#docs.set(docId, newDoc); this.#docs.set(docId, newDoc);
this.#touchDoc(docId);
peer.syncStates.set(docId, newSyncState); peer.syncStates.set(docId, newSyncState);
// Send response sync message back to this peer // 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); 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)`);
}
}
} }

View File

@ -32,10 +32,16 @@ function getEncryptionKeyId(docId: string): string | undefined {
export const syncServer = new SyncServer({ export const syncServer = new SyncServer({
participantMode: true, participantMode: true,
maxDocs: 500,
onDocChange: (docId, doc) => { onDocChange: (docId, doc) => {
const encryptionKeyId = getEncryptionKeyId(docId); const encryptionKeyId = getEncryptionKeyId(docId);
saveDoc(docId, doc, encryptionKeyId); 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) => { onRelayBackup: (docId, blob) => {
saveEncryptedBlob(docId, blob); saveEncryptedBlob(docId, blob);
}, },

39
shared/automerge-shim.ts Normal file
View File

@ -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 ?? {};

View File

@ -32,6 +32,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
#createFormOpen = false; #createFormOpen = false;
#createName = ''; #createName = '';
#createVisibility = 'public'; #createVisibility = 'public';
#onDocPointerDown: ((e: PointerEvent) => void) | null = null;
constructor() { constructor() {
super(); super();
@ -58,6 +59,13 @@ export class RStackSpaceSwitcher extends HTMLElement {
this.#render(); this.#render();
} }
disconnectedCallback() {
if (this.#onDocPointerDown) {
document.removeEventListener("pointerdown", this.#onDocPointerDown);
this.#onDocPointerDown = null;
}
}
async #loadSpaces() { async #loadSpaces() {
if (this.#loaded) return; if (this.#loaded) return;
try { try {
@ -123,13 +131,17 @@ export class RStackSpaceSwitcher extends HTMLElement {
}); });
// Close menu only when clicking outside both trigger and menu // 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; if (!menu.classList.contains("open")) return;
const path = e.composedPath(); const path = e.composedPath();
if (path.includes(menu) || path.includes(trigger)) return; if (path.includes(menu) || path.includes(trigger)) return;
this.#saveCreateFormState(menu); this.#saveCreateFormState(menu);
menu.classList.remove("open"); menu.classList.remove("open");
}); };
document.addEventListener("pointerdown", this.#onDocPointerDown);
// Prevent clicks inside menu from closing it // Prevent clicks inside menu from closing it
menu.addEventListener("pointerdown", (e) => e.stopPropagation()); menu.addEventListener("pointerdown", (e) => e.stopPropagation());

View File

@ -54,6 +54,10 @@ export class RSpaceOfflineRuntime {
#moduleScopes = new Map<string, 'global' | 'space'>(); #moduleScopes = new Map<string, 'global' | 'space'>();
/** Lazy WebSocket connections per space slug (for cross-space subscriptions). */ /** Lazy WebSocket connections per space slug (for cross-space subscriptions). */
#spaceConnections = new Map<string, DocSyncManager>(); #spaceConnections = new Map<string, DocSyncManager>();
/** 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) { constructor(space: string) {
this.#activeSpace = space; this.#activeSpace = space;
@ -322,10 +326,14 @@ export class RSpaceOfflineRuntime {
this.#activeSpace = newSpace; this.#activeSpace = newSpace;
// Update LRU access order
this.#touchSpaceAccess(newSpace);
// Re-use existing connection if we've visited this space before // Re-use existing connection if we've visited this space before
const existing = this.#spaceConnections.get(newSpace); const existing = this.#spaceConnections.get(newSpace);
if (existing) { if (existing) {
this.#sync = existing; this.#sync = existing;
this.#evictStaleSpaces();
return; return;
} }
@ -337,6 +345,9 @@ export class RSpaceOfflineRuntime {
this.#sync = newSync; this.#sync = newSync;
this.#spaceConnections.set(newSpace, newSync); this.#spaceConnections.set(newSpace, newSync);
// Evict old connections beyond the cap
this.#evictStaleSpaces();
// Connect lazily — will connect when first doc is subscribed // Connect lazily — will connect when first doc is subscribed
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${newSpace}`; const wsUrl = `${proto}//${location.host}/ws/${newSpace}`;
@ -443,6 +454,32 @@ export class RSpaceOfflineRuntime {
// ── Private ── // ── 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<void> { async #runStorageHousekeeping(): Promise<void> {
try { try {
// Request persistent storage (browser may grant silently) // Request persistent storage (browser may grant silently)

View File

@ -294,6 +294,17 @@ export class DocSyncManager {
for (const id of docIds) { for (const id of docIds) {
if (this.#subscribedDocs.delete(id)) { if (this.#subscribedDocs.delete(id)) {
removed.push(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);
}
} }
} }

View File

@ -2,22 +2,33 @@ import { resolve } from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm"; 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 // 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) { async function wasmBuild(config: any) {
const { build } = await import("vite"); const { build } = await import("vite");
const bundleAutomerge = config._bundleAutomerge ?? false;
const { _bundleAutomerge, ...restConfig } = config;
return build({ return build({
...config, ...restConfig,
plugins: [...(config.plugins || []), wasm()], plugins: [...(restConfig.plugins || []), wasm()],
resolve: { resolve: {
...config.resolve, ...restConfig.resolve,
alias: { alias: {
...(config.resolve?.alias || {}), ...(restConfig.resolve?.alias || {}),
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), '@automerge/automerge': bundleAutomerge
? resolve(__dirname, 'node_modules/@automerge/automerge')
: AUTOMERGE_SHIM,
}, },
}, },
build: { build: {
target: "esnext", 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) // Build shell.ts as a standalone JS bundle (needs wasm() for Automerge via runtime)
await wasmBuild({ await wasmBuild({
_bundleAutomerge: true,
configFile: false, configFile: false,
root: resolve(__dirname, "website"), root: resolve(__dirname, "website"),
plugins: [wasm()], plugins: [wasm()],
@ -62,7 +74,6 @@ export default defineConfig({
alias: { alias: {
"@lib": resolve(__dirname, "./lib"), "@lib": resolve(__dirname, "./lib"),
"@shared": resolve(__dirname, "./shared"), "@shared": resolve(__dirname, "./shared"),
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
}, },
}, },
build: { build: {
@ -358,18 +369,11 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rchoices/choices.css"), 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({ await wasmBuild({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/crowdsurf/components"), root: resolve(__dirname, "modules/crowdsurf/components"),
plugins: [wasm()],
resolve: {
alias: {
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
},
build: { build: {
target: "esnext",
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/crowdsurf"), outDir: resolve(__dirname, "dist/modules/crowdsurf"),
lib: { lib: {
@ -446,15 +450,10 @@ export default defineConfig({
await wasmBuild({ await wasmBuild({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rflows/components"), root: resolve(__dirname, "modules/rflows/components"),
plugins: [wasm()],
resolve: { resolve: {
alias: { alias: flowsAlias,
...flowsAlias,
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
}, },
build: { build: {
target: "esnext",
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rflows"), outDir: resolve(__dirname, "dist/modules/rflows"),
lib: { lib: {
@ -635,18 +634,11 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rvote/vote.css"), 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({ await wasmBuild({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rnotes/components"), root: resolve(__dirname, "modules/rnotes/components"),
plugins: [wasm()],
resolve: {
alias: {
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
},
build: { build: {
target: "esnext",
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rnotes"), outDir: resolve(__dirname, "dist/modules/rnotes"),
lib: { lib: {
@ -950,18 +942,11 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rsocials/socials.css"), 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({ await wasmBuild({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rsocials/components"), root: resolve(__dirname, "modules/rsocials/components"),
plugins: [wasm()],
resolve: {
alias: {
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
},
build: { build: {
target: "esnext",
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"), outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: { lib: {

View File

@ -5,11 +5,15 @@
* shell bundle stays small and doesn't block first paint. * 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 { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
import { RSpaceOfflineRuntime } from "../shared/local-first/runtime"; import { RSpaceOfflineRuntime } from "../shared/local-first/runtime";
import { CommunitySync } from "../lib/community-sync"; import { CommunitySync } from "../lib/community-sync";
import { OfflineStore } from "../lib/offline-store"; 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) // Define the history panel component (depends on Automerge)
RStackHistoryPanel.define(); RStackHistoryPanel.define();