From d5572e60819061fa7ad49b0db03e65f2a7e8a837 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Jan 2026 20:27:28 +0000 Subject: [PATCH] Add scroll restoration on browser refresh for SPA pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/RSpade/Core/Js/Rsx.js | 81 +++++++++++++++++++++++++++++++++----- app/RSpade/Core/SPA/Spa.js | 10 +++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/app/RSpade/Core/Js/Rsx.js b/app/RSpade/Core/Js/Rsx.js index c05722608..9e078416d 100755 --- a/app/RSpade/Core/Js/Rsx.js +++ b/app/RSpade/Core/Js/Rsx.js @@ -742,6 +742,13 @@ class Rsx { */ static _SCROLL_STORAGE_KEY = 'rsx_scroll_pos'; + /** + * Pending scroll restoration state + * Set when a refresh is detected, cleared when restoration succeeds + * @private + */ + static _pending_scroll = null; + /** * Save scroll position to sessionStorage on scroll (debounced) * Called from scroll event listener set up in _restore_scroll_on_refresh @@ -765,8 +772,8 @@ class Rsx { } /** - * Restore scroll position if this is a page refresh - * Uses Performance API to detect reload navigation type + * Initialize scroll restoration on page refresh + * Sets up scroll saving and queues restoration if this is a refresh * @private */ static _restore_scroll_on_refresh() { @@ -784,7 +791,7 @@ class Rsx { return; } - // This is a refresh - try to restore scroll position + // This is a refresh - load stored scroll position const stored = sessionStorage.getItem(Rsx._SCROLL_STORAGE_KEY); if (!stored) { return; @@ -796,24 +803,78 @@ class Rsx { // Only restore if URL matches if (scroll_data.url !== current_url) { + sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY); return; } - // Restore scroll position instantly - window.scrollTo({ - left: scroll_data.x, - top: scroll_data.y, - behavior: 'instant' - }); + // Queue the scroll restoration + Rsx._pending_scroll = { x: scroll_data.x, y: scroll_data.y }; - // Clear stored position after successful restore + // Clear stored position (we've loaded it into memory) sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY); + + // Try first restoration attempt + Rsx.try_restore_scroll(); } catch (e) { // Invalid JSON or other error - ignore sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY); } } + /** + * Attempt to restore scroll position if pending + * + * Can be called multiple times during page load. Checks if the page is tall + * enough to scroll to the target position. Once successful, subsequent calls + * are no-ops until the next page refresh. + * + * Call this after content that may increase page height has loaded + * (e.g., after SPA action on_ready completes). + * + * @returns {boolean} True if scroll was restored, false if pending/skipped + */ + static try_restore_scroll() { + // No pending restoration + if (!Rsx._pending_scroll) { + return false; + } + + const target = Rsx._pending_scroll; + + // Check if page is tall enough to scroll to target position + // Page needs to be at least (target.y + viewport height) tall + const page_height = document.documentElement.scrollHeight; + const viewport_height = window.innerHeight; + const min_height_needed = target.y + viewport_height; + + if (page_height < min_height_needed) { + // Page not tall enough yet - keep pending for retry + console_debug('Spa', `Scroll restore waiting: page ${page_height}px < needed ${min_height_needed}px`); + return false; + } + + // Page is tall enough - restore scroll position + window.scrollTo({ + left: target.x, + top: target.y, + behavior: 'instant' + }); + + // Mark as complete - clear pending state + Rsx._pending_scroll = null; + + console_debug('Spa', `Scroll restored to (${target.x}, ${target.y})`); + return true; + } + + /** + * Reset pending scroll state + * Called by SPA dispatch to clear any pending restoration from previous navigation + */ + static reset_pending_scroll() { + Rsx._pending_scroll = null; + } + /* Calling this stops the boot process. */ static async _rsx_core_boot_stop(reason) { console.error(reason); diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index a88755171..9010995a6 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -573,6 +573,10 @@ class Spa { // Errors from previous page's pending requests should be ignored for 10 seconds Spa._navigation_timestamp = Date.now(); + // Reset any pending scroll restoration from previous navigation + // (Browser refresh scroll is handled separately by Rsx._restore_scroll_on_refresh) + Rsx.reset_pending_scroll(); + try { const opts = { history: options.history || 'auto', @@ -938,6 +942,12 @@ class Spa { if (is_last) { // This is the action - set reference but don't wait Spa._action = component; + + // After action is fully ready (on_load + on_ready complete), retry scroll restoration + // This handles cases where on_load fetches data that increases page height + component.ready().then(() => { + Rsx.try_restore_scroll(); + }); } else { // This is a layout // Wait for render to complete (not full ready - we don't need child data to load)