From 4877daad8f51dcebd7222c9b203254b3be277f00 Mon Sep 17 00:00:00 2001 From: Elina Date: Thu, 2 Apr 2026 16:10:20 +0800 Subject: [PATCH 1/2] fix(history): prevent panic in HashHistory::location() on malformed hash URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HashHistory::location() called assert_absolute_path on the URL hash, which panicked if the hash didn't start with #/. Users can edit the URL bar at any time to produce hashes like #aaa or just #, crashing the application on the next location() call. Replace the assertion with graceful normalization: prepend / if missing, log a console warning, and auto-correct the URL via replaceState. This matches how all major JS router libraries (React Router, Vue Router) handle the same problem — none of them throw on a malformed hash. The push/replace methods continue to assert since those are developer-controlled inputs where a relative path is always a bug. Fixes #470 Co-Authored-By: Claude Opus 4.6 --- crates/history/Cargo.toml | 2 +- crates/history/src/hash.rs | 58 +++++++++++++++++++++++++--- crates/history/tests/hash_history.rs | 36 +++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/crates/history/Cargo.toml b/crates/history/Cargo.toml index edc5e397..f5dae048 100644 --- a/crates/history/Cargo.toml +++ b/crates/history/Cargo.toml @@ -23,7 +23,7 @@ wasm-bindgen = "0.2.114" [dependencies.web-sys] version = "0.3" -features = ["History", "Window", "Location", "Url"] +features = ["console", "History", "Window", "Location", "Url"] [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2.17", features = ["js"] } diff --git a/crates/history/src/hash.rs b/crates/history/src/hash.rs index 94e95fad..e1e6ceb8 100644 --- a/crates/history/src/hash.rs +++ b/crates/history/src/hash.rs @@ -16,7 +16,16 @@ use crate::{error::HistoryResult, query::ToQuery}; /// /// # Panics /// -/// HashHistory does not support relative paths and will panic if routes are not starting with `/`. +/// The `push` and `replace` family of methods do not support relative paths +/// and will panic if the provided route does not start with `/`. +/// +/// # Hash Normalization +/// +/// If the URL hash is manually edited by the user to a value that does not +/// start with `#/`, calling `location()` will **not** panic. Instead, it will: +/// 1. Log a warning to the browser console. +/// 2. Normalize the hash by prepending `/` if missing. +/// 3. Silently correct the URL in the address bar via `replaceState`. #[derive(Clone, PartialEq)] pub struct HashHistory { inner: BrowserHistory, @@ -195,19 +204,29 @@ impl History for HashHistory { fn location(&self) -> Location { let inner_loc = self.inner.location(); - // We strip # from hash. - let hash_url = inner_loc.hash().chars().skip(1).collect::(); - assert_absolute_path(&hash_url); + // Strip the leading '#' from the hash. + let raw_hash = inner_loc.hash().strip_prefix('#').unwrap_or("").to_string(); + + // Normalize: ensure it starts with '/'. Log a warning if it didn't. + let needs_correction = raw_hash.is_empty() || !raw_hash.starts_with('/'); + let normalized = Self::normalize_hash(&raw_hash); let hash_url = Url::new_with_base( - &hash_url, + &normalized, &window() .location() .href() .expect_throw("failed to get location href."), ) - .expect_throw("failed to get make url"); + .expect_throw("failed to make url"); + + // Auto-correct the URL in the address bar so it stays canonical. + if needs_correction { + let url = Self::get_url(); + url.set_hash(&format!("#{normalized}")); + self.inner.replace(url.href()); + } Location { path: hash_url.pathname().into(), @@ -225,6 +244,33 @@ impl HashHistory { Self::default() } + /// Takes the raw content after '#' and ensures it starts with '/'. + /// If it doesn't, prepends '/' and logs a warning. + /// If it is empty, returns "/". + fn normalize_hash(raw: &str) -> String { + if raw.is_empty() { + web_sys::console::warn_1( + &"[gloo_history] HashHistory: URL hash is empty, defaulting to '/'. \ + The hash was auto-corrected to '#/'." + .into(), + ); + "/".to_string() + } else if !raw.starts_with('/') { + web_sys::console::warn_1( + &format!( + "[gloo_history] HashHistory: URL hash '#{}' does not start with '/'. \ + The hash was normalized to '#/{}'. \ + Ensure hash-based routes always begin with '#/'.", + raw, raw + ) + .into(), + ); + format!("/{raw}") + } else { + raw.to_string() + } + } + fn get_url() -> Url { let href = window() .location() diff --git a/crates/history/tests/hash_history.rs b/crates/history/tests/hash_history.rs index abe8047a..a160c97e 100644 --- a/crates/history/tests/hash_history.rs +++ b/crates/history/tests/hash_history.rs @@ -51,3 +51,39 @@ async fn history_works() { delayed_assert_eq(|| window().location().pathname().unwrap(), || "/").await; delayed_assert_eq(|| window().location().hash().unwrap(), || "#/path-b").await; } + +#[test] +async fn location_does_not_panic_on_malformed_hash() { + let history = HashHistory::new(); + + // Simulate the user manually editing the URL bar to a hash without a leading '/' + window().location().set_hash("no-leading-slash").unwrap(); + + // This must NOT panic + let location = history.location(); + + // The path should have been normalized with a leading '/' + assert_eq!(location.path(), "/no-leading-slash"); + + // The URL should have been auto-corrected + delayed_assert_eq( + || window().location().hash().unwrap(), + || "#/no-leading-slash", + ) + .await; +} + +#[test] +async fn location_does_not_panic_on_empty_hash() { + let history = HashHistory::new(); + + // Simulate the user clearing the hash entirely + window().location().set_hash("").unwrap(); + + let location = history.location(); + + assert_eq!(location.path(), "/"); + + // The URL should have been auto-corrected + delayed_assert_eq(|| window().location().hash().unwrap(), || "#/").await; +} From 9bd9ef9ff43fd71e3b0fb3a9593de56e8cb8e039 Mon Sep 17 00:00:00 2001 From: Elina Date: Thu, 2 Apr 2026 16:46:10 +0800 Subject: [PATCH 2/2] Move new tests to `history_works` The browser history state is shared between tests in wasm-pack so separate tests can cause issues --- crates/history/tests/hash_history.rs | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/history/tests/hash_history.rs b/crates/history/tests/hash_history.rs index a160c97e..290a7a6a 100644 --- a/crates/history/tests/hash_history.rs +++ b/crates/history/tests/hash_history.rs @@ -8,6 +8,9 @@ wasm_bindgen_test_configure!(run_in_browser); mod utils; use utils::delayed_assert_eq; +// All assertions live in a single test because HashHistory is a thread-local +// singleton backed by a shared browser URL, so separate tests would leak +// state into each other depending on execution order. #[test] async fn history_works() { let history = HashHistory::new(); @@ -50,40 +53,24 @@ async fn history_works() { } delayed_assert_eq(|| window().location().pathname().unwrap(), || "/").await; delayed_assert_eq(|| window().location().hash().unwrap(), || "#/path-b").await; -} -#[test] -async fn location_does_not_panic_on_malformed_hash() { - let history = HashHistory::new(); - - // Simulate the user manually editing the URL bar to a hash without a leading '/' + // Malformed hash: simulate user editing the URL bar to a hash without '/' window().location().set_hash("no-leading-slash").unwrap(); - // This must NOT panic let location = history.location(); - - // The path should have been normalized with a leading '/' assert_eq!(location.path(), "/no-leading-slash"); - // The URL should have been auto-corrected delayed_assert_eq( || window().location().hash().unwrap(), || "#/no-leading-slash", ) .await; -} -#[test] -async fn location_does_not_panic_on_empty_hash() { - let history = HashHistory::new(); - - // Simulate the user clearing the hash entirely + // Empty hash: simulate user clearing the hash entirely window().location().set_hash("").unwrap(); let location = history.location(); - assert_eq!(location.path(), "/"); - // The URL should have been auto-corrected delayed_assert_eq(|| window().location().hash().unwrap(), || "#/").await; }