From 0fb4b03b48bda2ba187d96d1feb4459716b8e73c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 8 Jul 2026 08:19:32 -0700 Subject: Coalesce background renders so user input takes priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every wallet.emit() re-rendered the whole screen synchronously. On refocus after the PWA was backgrounded, a burst of emits (WS reconnect, the wake scan's several emits, syncFromNostr merge, each live device snapshot) fired a dozen full-screen rebuilds back-to-back, starving input — navigation lagged ~2s until the burst drained. Route the subscription through scheduleRender(), which coalesces emits into at most one render per animation frame. Direct user actions still call render() synchronously and cancel any pending frame, so a tap or navigation always renders immediately and takes priority over background repaints. rAF also skips rendering while hidden and fires once on refocus. Co-Authored-By: Claude Opus 4.8 --- src/app.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 547cc65..b0a5fec 100644 --- a/src/app.js +++ b/src/app.js @@ -274,6 +274,9 @@ function nodeAtPath(path) { } function render() { + // A direct render subsumes any coalesced one queued by a background emit, so + // drop the pending frame (a user action must not trigger a second rebuild). + if (_renderRaf) { cancelAnimationFrame(_renderRaf); _renderRaf = 0; } // Preserve focus + caret across the rebuild, so a background update (poll, a // payment push, an SP scan) can't kick the user out of a field they're editing. const a = document.activeElement; @@ -312,7 +315,22 @@ function render() { } syncHistory(); // mirror the current screen into browser history (Back/Forward) } -wallet.subscribe(render); + +// Background state changes (a wake scan, cross-device sync merges, WS payment +// pushes) can fire a burst of emits — especially when the PWA is refocused +// after being backgrounded. Rendering synchronously on each one rebuilds the +// whole screen dozens of times in a row and starves user input, so the app +// feels frozen for a second or two. Coalesce those into at most one render per +// animation frame. Direct user actions still call render() synchronously, and +// the browser dispatches input events ahead of the queued frame, so a tap or +// navigation always takes priority over background repaints. (rAF also skips +// rendering entirely while the tab is hidden, and fires once on refocus.) +let _renderRaf = 0; +function scheduleRender() { + if (_renderRaf) return; + _renderRaf = requestAnimationFrame(() => { _renderRaf = 0; render(); }); +} +wallet.subscribe(scheduleRender); // ---- browser-history navigation ------------------------------------------ // Mirror the app's screen position into the browser history so the Back/Forward -- cgit v1.2.3