From 7766c80c6aeb934b14297091b86230d7877f92a3 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 20:26:52 +0100 Subject: [PATCH] Add support for browser back/forward --- integ_tests/test_scenes.py | 21 +++++ .../js/scenes/CollectionsScene.js | 1 + .../js/scenes/CreateEditCollectionScene.js | 1 + .../js/scenes/CreateEditShareScene.js | 1 + .../js/scenes/DeleteConfirmationScene.js | 1 + .../js/scenes/IncomingSharingScene.js | 1 + .../internal_data/js/scenes/LoadingScene.js | 1 + .../web/internal_data/js/scenes/LoginScene.js | 1 + .../js/scenes/ShareCollectionScene.js | 1 + .../js/scenes/UploadCollectionScene.js | 1 + .../internal_data/js/scenes/scene_manager.js | 91 +++++++++++++++++++ 11 files changed, 121 insertions(+) diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py index 1fd30061..68d1cefd 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -126,3 +126,24 @@ def test_navigation_refresh_button( page.click('#logoutview a[data-name="refresh"]') # It shows LoadingScene briefly then back to CollectionsScene expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_browser_history( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + login(page, radicale_server, config, context=context) + expect(page.locator("#collectionsscene")).to_be_visible() + + # Navigate forward inside the SPA to CreateEditCollectionScene + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + # Emulate browser Back button + page.go_back() + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + # Emulate browser Forward button + page.go_forward() + expect(page.locator("#collectionsscene")).to_be_hidden() + expect(page.locator("#createcollectionscene")).to_be_visible() diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index a22c6324..8a7e6277 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -290,5 +290,6 @@ export class CollectionsScene { }; this.release = function () { }; + this.is_transient = function () { return false; }; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index 422c36ac..efa7206d 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -211,6 +211,7 @@ export class CreateEditCollectionScene { submit_btn.onclick = null; cancel_btn.onclick = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (create_edit_req !== null) { create_edit_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 9bc79d14..a16f8bf4 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -365,5 +365,6 @@ export class CreateEditShareScene { this.release = function () { }; + this.is_transient = function () { return false; }; } } diff --git a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js index d0d9b5a0..e2f9ffa7 100644 --- a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js @@ -134,6 +134,7 @@ export class DeleteConfirmationScene { cancel_btn.onclick = null; delete_btn.onclick = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (delete_req !== null) { delete_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index 608cd71e..fad42a41 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -157,6 +157,7 @@ export class IncomingSharingScene { error_handler.clearError(); }; + this.is_transient = function () { return false; }; this.release = function () { error_handler.clearError(); nodes.forEach(function (node) { diff --git a/radicale/web/internal_data/js/scenes/LoadingScene.js b/radicale/web/internal_data/js/scenes/LoadingScene.js index fd1f5378..1dc5341c 100644 --- a/radicale/web/internal_data/js/scenes/LoadingScene.js +++ b/radicale/web/internal_data/js/scenes/LoadingScene.js @@ -36,4 +36,5 @@ export class LoadingScene { this.html_scene.classList.add("hidden"); } release() { } + is_transient() { return true; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index aff56d6a..ca5f33de 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -183,6 +183,7 @@ export class LoginScene { html_scene.classList.add("hidden"); form.onsubmit = null; }; + this.is_transient = function () { return false; }; this.release = function () { // cancel pending requests if (principal_req !== null) { diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 99c5d301..3c1ea910 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -126,6 +126,7 @@ export class ShareCollectionScene { }; this.release = function () { }; + this.is_transient = function () { return false; }; } } diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index 3b11e654..95845868 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -227,6 +227,7 @@ export class UploadCollectionScene { }); nodes = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (upload_req !== null) { upload_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/scene_manager.js b/radicale/web/internal_data/js/scenes/scene_manager.js index 0e79955c..efba9026 100644 --- a/radicale/web/internal_data/js/scenes/scene_manager.js +++ b/radicale/web/internal_data/js/scenes/scene_manager.js @@ -36,6 +36,11 @@ export class Scene { * Scene is removed from scene stack. */ release() { } + /** + * Whether the scene should be excluded from browser history. + * @returns boolean + */ + is_transient() { return false; } } @@ -44,6 +49,87 @@ export class Scene { */ let scene_stack = []; +/** @type {Array>} */ +let history_array = []; +let current_history_index = -1; +let is_navigating_history = false; + +function record_history() { + if (is_navigating_history) return; + + let current_scene = scene_stack.length > 0 ? scene_stack[scene_stack.length - 1] : null; + if (current_scene && current_scene.is_transient && current_scene.is_transient()) { + return; + } + + // Compare with current history to avoid duplicates + if (history_array.length > 0 && current_history_index >= 0) { + let last_stack = history_array[current_history_index]; + if (last_stack && last_stack.length === scene_stack.length) { + let is_identical = true; + for (let i = 0; i < scene_stack.length; i++) { + if (last_stack[i] !== scene_stack[i]) { + is_identical = false; + break; + } + } + if (is_identical) return; + } + } + + history_array.splice(current_history_index + 1); + history_array.push(scene_stack.slice()); + current_history_index++; + + // Check if this is the first history entry we are recording + if (typeof window !== "undefined" && window.history) { + if (current_history_index === 0) { + history.replaceState({ history_index: current_history_index }, ''); + } else { + history.pushState({ history_index: current_history_index }, ''); + } + } +} + +if (typeof window !== "undefined" && window.history) { + if (!history.state || typeof history.state.history_index !== "number") { + current_history_index = -1; + } else { + // If there's an existing state but we just loaded, we don't have the history_array memory. + // We'll reset it. The user will be redirected to the root if they go back out of bounds. + current_history_index = -1; + } + + window.addEventListener("popstate", (event) => { + if (!event.state || typeof event.state.history_index !== "number") return; + let new_index = event.state.history_index; + + if (new_index >= 0 && new_index < history_array.length) { + is_navigating_history = true; + try { + let new_stack = history_array[new_index]; + + if (scene_stack.length > 0) { + scene_stack[scene_stack.length - 1].hide(); + } + + scene_stack = new_stack.slice(); + + if (scene_stack.length > 0) { + scene_stack[scene_stack.length - 1].show(); + } + + current_history_index = new_index; + } finally { + is_navigating_history = false; + } + } else { + // Out of bounds (e.g., from a previous session after a reload) + window.location.reload(); + } + }); +} + /** * Push scene onto stack. * @param {Scene} scene @@ -54,6 +140,7 @@ export function push_scene(scene) { } scene_stack.push(scene); scene.show(); + record_history(); } /** @@ -80,6 +167,7 @@ export function replace_scene(scene) { } scene_stack.push(scene); scene.show(); + record_history(); } /** @@ -94,6 +182,7 @@ export function pop_scene() { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); } + record_history(); } /** @@ -112,6 +201,7 @@ export function pop_to_parent() { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); } + record_history(); } /** @@ -131,6 +221,7 @@ export function pop_to_root() { if (scene_stack.length === 1) { scene_stack[0].show(); // Ensure the root scene is visible } + record_history(); } /**