From 6218a46b57496099baf05e3ea49ea856cdf984f9 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 23:29:16 -0700 Subject: [PATCH] perf(web): cache macro images in local PWA, bump to 1.1.2 Same fix as the relay: the local PWA re-fetched every macro image and revoked the blob on each re-render. Cache image URL -> Promise and reuse across renders (images are immutable per uuid path); add Cache-Control: immutable on the desktop /api/image response. Verified in a browser: 7 re-renders produced only the initial 3 image fetches, none repeated. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.py | 2 +- version.txt | 2 +- web/js/app.js | 41 +++++++++++++++++++++++++---------------- web_server.py | 6 +++++- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/config.py b/config.py index afaadf7..f94c029 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ # Configuration and constants for MacroPad Server -VERSION = "1.1.1" +VERSION = "1.1.2" DEFAULT_PORT = 40000 SETTINGS_FILE = "settings.json" diff --git a/version.txt b/version.txt index 8cfbc90..8428158 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.1 \ No newline at end of file +1.1.2 \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js index 6eb12b7..9ce870d 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -34,8 +34,10 @@ class MacroPadApp { // Guards against out-of-order macro fetches this._macroReqId = 0; - // Blob object URLs created for macro images (revoked on re-render) - this._objectUrls = []; + // Cache of image URL -> Promise. Macro images are + // content-addressed by uuid filename, so they are immutable; caching + // avoids re-fetching every image on each re-render. + this._imageCache = new Map(); // Set once a local-mode auth failure has been surfaced, to avoid // reconnect loops and repeated toasts. @@ -451,16 +453,12 @@ class MacroPadApp { return headers; } - // Fetch a macro image with the auth header and display it as a blob - // object URL, so no credential is ever placed on an src. + // Fetch a macro image with the auth header and display it as a blob object + // URL, so no credential is ever placed on an src. The fetched blob is + // cached (images are immutable per URL) and reused across re-renders. async loadMacroImage(url, img, placeholder) { try { - const res = await fetch(url, { headers: this.getAuthHeaders() }); - if (!res.ok) throw new Error('image request failed'); - const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - // Track for best-effort revocation on the next render. - this._objectUrls.push(objectUrl); + const objectUrl = await this._getImageUrl(url); img.src = objectUrl; } catch (e) { // Fall back to the placeholder if the image can't be loaded. @@ -469,6 +467,23 @@ class MacroPadApp { } } + // Returns a cached Promise for an image URL, fetching once and + // retaining it for the page's lifetime (immutable content, nothing to + // invalidate); de-duplicates concurrent requests too. + _getImageUrl(url) { + let entry = this._imageCache.get(url); + if (entry) return entry; + entry = (async () => { + const res = await fetch(url, { headers: this.getAuthHeaders() }); + if (!res.ok) throw new Error('image request failed'); + const blob = await res.blob(); + return URL.createObjectURL(blob); + })(); + entry.catch(() => this._imageCache.delete(url)); + this._imageCache.set(url, entry); + return entry; + } + // Rendering (safe DOM construction only - no user value reaches innerHTML) renderTabs() { const container = document.getElementById('tabs-container'); @@ -492,12 +507,6 @@ class MacroPadApp { const container = document.getElementById('macro-grid'); if (!container) return; - // Best-effort: revoke object URLs from the previous render to avoid leaks. - if (this._objectUrls && this._objectUrls.length) { - this._objectUrls.forEach((u) => URL.revokeObjectURL(u)); - } - this._objectUrls = []; - container.textContent = ''; const macroEntries = Object.entries(this.macros); diff --git a/web_server.py b/web_server.py index 9d0e10d..3dc3066 100644 --- a/web_server.py +++ b/web_server.py @@ -338,7 +338,11 @@ class WebServer: # Only serve files that resolve to inside the macro_images directory if (os.path.commonpath([requested, images_real]) == images_real and os.path.isfile(requested)): - return FileResponse(requested) + # Macro images are content-addressed (uuid filenames) and thus + # immutable, so let the browser cache them aggressively. + return FileResponse(requested, headers={ + "Cache-Control": "private, max-age=31536000, immutable" + }) raise HTTPException(status_code=404, detail="Image not found") @app.websocket("/ws")