From 8e39b0ed8098f48d04457be00d6562a68acf507e Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Wed, 6 May 2026 15:43:09 +0100 Subject: [PATCH 1/5] Improve the browser navigation interations --- .../wwwroot/hash-routing.module.js | 167 ++++++-- .../HashRoutingJavaScriptBehaviorTests.cs | 381 +++++++++++++++++- 2 files changed, 510 insertions(+), 38 deletions(-) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index f64f386..7523a44 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -20,7 +20,12 @@ const hashRoutingState = { hashChangeHandler: null, popStateHandler: null, anchorMutationObserver: null, - processingBrowserNavigation: false, + processingInterceptedLinkNavigation: false, + currentBrowserNavigationId: 0, + activeBrowserNavigationKey: "", + pendingBrowserNavigation: null, + suppressedBrowserNavigationEventResolvers: [], + lastSuppressedBrowserNavigationKey: "", lastProcessedBrowserNavigationKey: "", }; @@ -48,6 +53,11 @@ export function initialize(dotNetObjectReference, options, baseUri, currentPathU hashRoutingState.currentHistoryIndex = getHistoryIndex(window.history.state); hashRoutingState.lastAcceptedHashAbsoluteUri = initialHashAbsoluteUri; hashRoutingState.lastAcceptedHistoryState = getHistoryUserState(window.history.state); + hashRoutingState.currentBrowserNavigationId = 0; + hashRoutingState.activeBrowserNavigationKey = ""; + hashRoutingState.pendingBrowserNavigation = null; + hashRoutingState.suppressedBrowserNavigationEventResolvers = []; + hashRoutingState.lastSuppressedBrowserNavigationKey = ""; hashRoutingState.lastProcessedBrowserNavigationKey = ""; const canCanonicalizeInitialHash = isCanonicalizableHash(window.location.hash, hashRoutingState.normalizedHashPrefix); @@ -60,6 +70,8 @@ export function initialize(dotNetObjectReference, options, baseUri, currentPathU } export function navigateTo(pathAbsoluteUri, replaceHistoryEntry, historyEntryState) { + ignorePendingBrowserNavigation(); + const normalizedHistoryEntryState = normalizeHistoryState(historyEntryState); const shouldReplaceHistoryEntry = Boolean(replaceHistoryEntry); @@ -122,6 +134,11 @@ export function dispose() { hashRoutingState.anchorMutationObserver.disconnect(); } hashRoutingState.anchorMutationObserver = null; + hashRoutingState.currentBrowserNavigationId++; + hashRoutingState.activeBrowserNavigationKey = ""; + hashRoutingState.pendingBrowserNavigation = null; + hashRoutingState.suppressedBrowserNavigationEventResolvers = []; + hashRoutingState.lastSuppressedBrowserNavigationKey = ""; hashRoutingState.lastProcessedBrowserNavigationKey = ""; } @@ -163,16 +180,15 @@ function attachHandlers() { } event.preventDefault(); - void processInterceptedLinkNavigation(absoluteHrefUrl.href); + return processInterceptedLinkNavigation(absoluteHrefUrl.href); }; hashRoutingState.hashChangeHandler = function () { - void processBrowserNavigation(window.location.href, false); + return processBrowserNavigation(window.location.href, false); }; hashRoutingState.popStateHandler = function () { - hashRoutingState.currentHistoryIndex = getHistoryIndex(window.history.state); - void processBrowserNavigation(window.location.href, false); + return processBrowserNavigation(window.location.href, false); }; document.addEventListener("click", hashRoutingState.clickHandler, true); @@ -287,40 +303,49 @@ function tryCreateAnchorAbsoluteHrefUrl(anchor) { } async function processBrowserNavigation(rawLocation, interceptedLink) { - if (hashRoutingState.processingBrowserNavigation) { + const browserNavigation = createBrowserNavigation(rawLocation); + if (!browserNavigation) { return; } - const historyState = window.history.state; - const historyIndex = getHistoryIndex(historyState); - const historyEntryState = normalizeHistoryState(getHistoryUserState(historyState)); - let destination; - try { - destination = new URL(rawLocation, hashRoutingState.baseUri); - } catch { + if (completeSuppressedBrowserNavigationEvent(browserNavigation.navigationKey)) { return; } - const browserNavigationKey = createBrowserNavigationKey(destination.href, historyIndex, historyEntryState); - if (browserNavigationKey === hashRoutingState.lastProcessedBrowserNavigationKey) { + if (browserNavigation.navigationKey === hashRoutingState.lastProcessedBrowserNavigationKey + || browserNavigation.navigationKey === hashRoutingState.activeBrowserNavigationKey + || browserNavigation.navigationKey === hashRoutingState.lastSuppressedBrowserNavigationKey) { return; } - hashRoutingState.lastProcessedBrowserNavigationKey = browserNavigationKey; - hashRoutingState.processingBrowserNavigation = true; - hashRoutingState.currentHistoryIndex = historyIndex; + const browserNavigationId = ++hashRoutingState.currentBrowserNavigationId; + const previousHistoryIndex = hashRoutingState.currentHistoryIndex; + const historyDelta = browserNavigation.historyIndex - previousHistoryIndex; + const canRevertWithHistory = hashRoutingState.navigationLockEnabled + && historyDelta !== 0 + && typeof window.history.go === "function"; + + hashRoutingState.activeBrowserNavigationKey = browserNavigation.navigationKey; + hashRoutingState.pendingBrowserNavigation = browserNavigation; try { - if (!isCanonicalizableHash(destination.hash, hashRoutingState.normalizedHashPrefix)) { + if (canRevertWithHistory) { + await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta); + } + + if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { return; } - const targetPathAbsoluteUri = toPathAbsoluteUriFromAbsolute(destination, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); - const targetHashAbsoluteUri = toHashAbsoluteUri(targetPathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + const shouldContinue = hashRoutingState.navigationLockEnabled + ? await canContinueNavigation(browserNavigation.targetPathAbsoluteUri, browserNavigation.historyEntryState, interceptedLink) + : true; + if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { + return; + } - const shouldContinue = await canContinueNavigation(targetPathAbsoluteUri, historyEntryState, interceptedLink); if (!shouldContinue) { - if (hashRoutingState.lastAcceptedHashAbsoluteUri) { + if (!canRevertWithHistory && hashRoutingState.lastAcceptedHashAbsoluteUri) { const rollbackState = withHistoryMetadata(window.history.state, hashRoutingState.lastAcceptedHistoryState, hashRoutingState.currentHistoryIndex); window.history.replaceState(rollbackState, "", hashRoutingState.lastAcceptedHashAbsoluteUri); } @@ -328,26 +353,41 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { return; } - if (!sameUri(window.location.href, targetHashAbsoluteUri)) { - const replacementState = withHistoryMetadata(window.history.state, historyEntryState, hashRoutingState.currentHistoryIndex); - window.history.replaceState(replacementState, "", targetHashAbsoluteUri); + if (canRevertWithHistory) { + await navigateHistoryWithoutBrowserNavigationEvents(historyDelta); + } + + if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { + return; + } + + if (!sameUri(window.location.href, browserNavigation.targetHashAbsoluteUri)) { + const replacementState = withHistoryMetadata(window.history.state, browserNavigation.historyEntryState, browserNavigation.historyIndex); + window.history.replaceState(replacementState, "", browserNavigation.targetHashAbsoluteUri); } - hashRoutingState.lastAcceptedHashAbsoluteUri = targetHashAbsoluteUri; - hashRoutingState.lastAcceptedHistoryState = historyEntryState; + hashRoutingState.currentHistoryIndex = browserNavigation.historyIndex; + hashRoutingState.lastAcceptedHashAbsoluteUri = browserNavigation.targetHashAbsoluteUri; + hashRoutingState.lastAcceptedHistoryState = browserNavigation.historyEntryState; + hashRoutingState.lastProcessedBrowserNavigationKey = browserNavigation.navigationKey; + hashRoutingState.lastSuppressedBrowserNavigationKey = ""; - notifyLocationChanged(targetPathAbsoluteUri, historyEntryState, interceptedLink); + notifyLocationChanged(browserNavigation.targetPathAbsoluteUri, browserNavigation.historyEntryState, interceptedLink); } finally { - hashRoutingState.processingBrowserNavigation = false; + if (hashRoutingState.activeBrowserNavigationKey === browserNavigation.navigationKey) { + hashRoutingState.activeBrowserNavigationKey = ""; + hashRoutingState.pendingBrowserNavigation = null; + } } } async function processInterceptedLinkNavigation(rawLocation) { - if (hashRoutingState.processingBrowserNavigation) { + if (hashRoutingState.processingInterceptedLinkNavigation) { return; } - hashRoutingState.processingBrowserNavigation = true; + ignorePendingBrowserNavigation(); + hashRoutingState.processingInterceptedLinkNavigation = true; try { const destination = new URL(rawLocation, hashRoutingState.baseUri); @@ -359,7 +399,9 @@ async function processInterceptedLinkNavigation(rawLocation) { const targetHashAbsoluteUri = toHashAbsoluteUri(targetPathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); const historyEntryState = null; - const shouldContinue = await canContinueNavigation(targetPathAbsoluteUri, historyEntryState, true); + const shouldContinue = hashRoutingState.navigationLockEnabled + ? await canContinueNavigation(targetPathAbsoluteUri, historyEntryState, true) + : true; if (!shouldContinue) { return; } @@ -367,10 +409,67 @@ async function processInterceptedLinkNavigation(rawLocation) { commitNavigation(targetHashAbsoluteUri, false, historyEntryState); notifyLocationChanged(targetPathAbsoluteUri, historyEntryState, true); } finally { - hashRoutingState.processingBrowserNavigation = false; + hashRoutingState.processingInterceptedLinkNavigation = false; } } +function ignorePendingBrowserNavigation() { + hashRoutingState.currentBrowserNavigationId++; + hashRoutingState.activeBrowserNavigationKey = ""; + hashRoutingState.pendingBrowserNavigation = null; +} + +function createBrowserNavigation(rawLocation) { + const historyState = window.history.state; + const historyIndex = getHistoryIndex(historyState); + const historyEntryState = normalizeHistoryState(getHistoryUserState(historyState)); + let destination; + try { + destination = new URL(rawLocation, hashRoutingState.baseUri); + } catch { + return null; + } + + if (!isCanonicalizableHash(destination.hash, hashRoutingState.normalizedHashPrefix)) { + return null; + } + + const targetPathAbsoluteUri = toPathAbsoluteUriFromAbsolute(destination, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + const targetHashAbsoluteUri = toHashAbsoluteUri(targetPathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + const navigationKey = createBrowserNavigationKey(targetHashAbsoluteUri, historyIndex, historyEntryState); + + return { + historyIndex, + historyEntryState, + targetPathAbsoluteUri, + targetHashAbsoluteUri, + navigationKey, + }; +} + +function completeSuppressedBrowserNavigationEvent(navigationKey) { + const resolver = hashRoutingState.suppressedBrowserNavigationEventResolvers.shift(); + if (!resolver) { + return false; + } + + hashRoutingState.lastSuppressedBrowserNavigationKey = navigationKey; + resolver(); + + return true; +} + +function navigateHistoryWithoutBrowserNavigationEvents(delta) { + if (delta === 0 || typeof window.history.go !== "function") { + return Promise.resolve(); + } + + return new Promise(function (resolve) { + hashRoutingState.suppressedBrowserNavigationEventResolvers.push(resolve); + window.history.go(delta); + }); +} + async function canContinueNavigation(pathAbsoluteUri, historyEntryState, interceptedLink) { if (!hashRoutingState.navigationLockEnabled) { return true; diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 3570b5c..1b7a6bf 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -194,6 +194,113 @@ public void GIVEN_InternalAnchorWithNonSelfTarget_WHEN_InitializeCalled_THEN_Anc _target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/settings"); } + [Fact] + public void GIVEN_RapidBrowserNavigationsBeforeLockCheckRuns_WHEN_LatestNavigationAllowed_THEN_BlazorIsNotifiedForLatestBrowserUrlOnly() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.NavigateTo("http://localhost/third", false, "third"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(true); + + _target.HistoryGo(-2); + _target.HistoryGo(1); + _target.ProcessTasks(); + + _target.GetLocationChangingCalls().Last().Location.Should().Be("http://localhost/second"); + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Last().Location.Should().Be("http://localhost/second"); + } + + [Fact] + public void GIVEN_StaleDeniedBrowserNavigation_WHEN_NewerNavigationIsPending_THEN_StaleResultDoesNotRollback() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.NavigateTo("http://localhost/third", false, "third"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingInvalidateSideEffect("http://localhost/#/second", 3, "second"); + _target.EnqueueLocationChangingResult(false); + + _target.HashChangeTo("http://localhost/#/first", 3, "first"); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Should().BeEmpty(); + } + + [Fact] + public void GIVEN_StaleAllowedBrowserNavigation_WHEN_NewerNavigationIsPending_THEN_StaleResultDoesNotNotify() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.NavigateTo("http://localhost/third", false, "third"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingHashChangeSideEffect("http://localhost/#/second", 3, "second"); + _target.EnqueueLocationChangingResult(true); + _target.EnqueueLocationChangingResult(true); + + _target.HashChangeTo("http://localhost/#/first", 3, "first"); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/second"); + } + + [Fact] + public void GIVEN_PopStateAndHashChangePair_WHEN_BrowserNavigates_THEN_BlazorIsNotifiedOnce() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/first"); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/first"); + } + + [Fact] + public void GIVEN_LatestBrowserNavigationDenied_WHEN_LockCheckCompletes_THEN_BrowserRemainsOnLastAcceptedUrl() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(false); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Should().BeEmpty(); + } + + [Fact] + public void GIVEN_InternalAnchorClicked_WHEN_LinkNavigationAllowed_THEN_LinkNavigationStillPushesHistoryAndNotifiesOnce() + { + var anchorIndex = _target.AppendAnchor("settings"); + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + + _target.ClickAnchor(anchorIndex); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/settings"); + _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState + { + HistoryIndex = 1, + UserState = null + }); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Should().BeEquivalentTo(new BrowserLocationChangedCall + { + Location = "http://localhost/settings", + HistoryEntryState = null, + IsNavigationIntercepted = true + }); + } + private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -217,6 +324,20 @@ public HashRoutingJavaScriptTestHost() return JsonSerializer.Deserialize(json, _options); } + public IReadOnlyList GetLocationChangingCalls() + { + var json = _engine.Invoke("__getLocationChangingCallsJson").AsString(); + + return JsonSerializer.Deserialize>(json, _options) ?? []; + } + + public IReadOnlyList GetLocationChangedCalls() + { + var json = _engine.Invoke("__getLocationChangedCallsJson").AsString(); + + return JsonSerializer.Deserialize>(json, _options) ?? []; + } + public string GetLastReplacedHref() { return _engine.Invoke("__getLastReplacedHref").AsString(); @@ -251,6 +372,11 @@ public string GetAnchorHref(int index) return _engine.Invoke("__getAnchorHref", index).AsString(); } + public void ClickAnchor(int index) + { + _engine.Invoke("__clickAnchor", index); + } + public string Initialize(string baseUri, string locationHref, string currentPathUri, object? options = null) { _engine.Invoke("__setDocumentBaseUri", baseUri); @@ -263,7 +389,7 @@ public string Initialize(string baseUri, string locationHref, string currentPath interceptInternalLinks = true }; - return _engine.Invoke("initialize", _engine.GetValue("dotNetObjectReference"), initializeOptions, baseUri, currentPathUri).AsString(); + return _engine.Invoke("initialize", _engine.Evaluate("dotNetObjectReference"), initializeOptions, baseUri, currentPathUri).AsString(); } public void NavigateExternally(string uri, bool replaceHistoryEntry) @@ -281,6 +407,49 @@ public void NavigateTo(string pathAbsoluteUri, bool replaceHistoryEntry, string? _engine.Invoke("navigateTo", pathAbsoluteUri, replaceHistoryEntry, historyEntryState ?? JsValue.Null); } + public void SetNavigationLockState(bool value) + { + _engine.Invoke("setNavigationLockState", value); + } + + public void EnqueueLocationChangingResult(bool value) + { + _engine.Invoke("__enqueueLocationChangingResult", value); + } + + public void EnqueueLocationChangingHistoryGoSideEffect(int delta) + { + _engine.Invoke("__enqueueLocationChangingHistoryGoSideEffect", delta); + } + + public void EnqueueLocationChangingHashChangeSideEffect(string href, int historyIndex, string? userState) + { + _engine.Invoke("__enqueueLocationChangingHashChangeSideEffect", href, historyIndex, userState ?? JsValue.Null); + } + + public void EnqueueLocationChangingInvalidateSideEffect(string href, int historyIndex, string? userState) + { + _engine.Invoke("__enqueueLocationChangingInvalidateSideEffect", href, historyIndex, userState ?? JsValue.Null); + } + + public void HistoryGo(int delta) + { + _engine.Invoke("__historyGo", delta); + } + + public void HashChangeTo(string href, int historyIndex, string? userState) + { + _engine.Invoke("__hashChangeTo", href, historyIndex, userState ?? JsValue.Null); + } + + public void ProcessTasks() + { + for (var index = 0; index < 20; index++) + { + _engine.Advanced.ProcessTasks(); + } + } + public void SetLocationAndHistory(string href, BrowserHistoryState? state) { if (state is null) @@ -435,7 +604,52 @@ function MutationObserver(callback) { }; const dotNetObjectReference = { - invokeMethodAsync: function() { + _locationChangingCalls: [], + _locationChangedCalls: [], + _locationChangingResults: [], + _locationChangingHistoryGoSideEffects: [], + _locationChangingHashChangeSideEffects: [], + _locationChangingInvalidateSideEffects: [], + invokeMethodAsync: function(methodName, location, historyEntryState, isNavigationIntercepted) { + if (methodName === "NotifyLocationChangingFromJs") { + this._locationChangingCalls.push({ + location: location, + historyEntryState: historyEntryState, + isNavigationIntercepted: Boolean(isNavigationIntercepted) + }); + + if (this._locationChangingHistoryGoSideEffects.length > 0) { + window.history.go(this._locationChangingHistoryGoSideEffects.shift()); + } + + if (this._locationChangingHashChangeSideEffects.length > 0) { + const sideEffect = this._locationChangingHashChangeSideEffects.shift(); + __hashChangeTo(sideEffect.href, sideEffect.historyIndex, sideEffect.userState); + } + + if (this._locationChangingInvalidateSideEffects.length > 0) { + const sideEffect = this._locationChangingInvalidateSideEffects.shift(); + __setLocationAndHistoryEntry(sideEffect.href, sideEffect.historyIndex, sideEffect.userState); + hashRoutingState.currentBrowserNavigationId++; + } + + const result = this._locationChangingResults.length > 0 + ? this._locationChangingResults.shift() + : true; + + return Boolean(result); + } + + if (methodName === "NotifyLocationChangedFromJs") { + this._locationChangedCalls.push({ + location: location, + historyEntryState: historyEntryState, + isNavigationIntercepted: Boolean(isNavigationIntercepted) + }); + + return null; + } + return null; } }; @@ -484,16 +698,60 @@ function MutationObserver(callback) { } }, history: { + _entries: [], + _entryIndex: -1, state: null, pushState: function(state, unused, uri) { + const href = new URL(uri, window.location.href).href; + this._entries = this._entries.slice(0, this._entryIndex + 1); + this._entries.push({ + href: href, + state: state + }); + this._entryIndex = this._entries.length - 1; this.state = state; - window.location.href = new URL(uri, window.location.href).href; + window.location.href = href; __syncLocation(); }, replaceState: function(state, unused, uri) { + const href = new URL(uri, window.location.href).href; + if (this._entryIndex < 0) { + this._entries.push({ + href: href, + state: state + }); + this._entryIndex = 0; + } else { + this._entries[this._entryIndex] = { + href: href, + state: state + }; + } this.state = state; - window.location.href = new URL(uri, window.location.href).href; + window.location.href = href; __syncLocation(); + }, + go: function(delta) { + const nextIndex = this._entryIndex + Number(delta); + if (nextIndex < 0 || nextIndex >= this._entries.length) { + return; + } + + const previousHash = window.location.hash; + const entry = this._entries[nextIndex]; + this._entryIndex = nextIndex; + this.state = entry.state; + window.location.href = entry.href; + __syncLocation(); + const popStateResult = __dispatchWindowEvent("popstate", { + state: this.state + }); + + if (window.location.hash !== previousHash) { + return __dispatchWindowEvent("hashchange", {}) || popStateResult; + } + + return popStateResult; } }, addEventListener: function(name, handler) { @@ -508,6 +766,14 @@ function __getHistoryStateJson() { return JSON.stringify(window.history.state); } +function __getLocationChangingCallsJson() { + return JSON.stringify(dotNetObjectReference._locationChangingCalls); +} + +function __getLocationChangedCallsJson() { + return JSON.stringify(dotNetObjectReference._locationChangedCalls); +} + function __getLastReplacedHref() { return window._lastReplacedHref; } @@ -547,10 +813,37 @@ function __getAnchorHref(index) { return document._anchors[index].href; } +function __clickAnchor(index) { + const anchor = document._anchors[index]; + const handler = document._events.click; + if (!handler) { + return; + } + + return handler({ + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: anchor, + preventDefault: function() { + this.defaultPrevented = true; + } + }); +} + function __setLocationAndHistory(href, historyIndex, userState) { window._lastReplacedHref = ""; window._lastReloadedHref = ""; window._reloadCount = 0; + dotNetObjectReference._locationChangingCalls = []; + dotNetObjectReference._locationChangedCalls = []; + dotNetObjectReference._locationChangingResults = []; + dotNetObjectReference._locationChangingHistoryGoSideEffects = []; + dotNetObjectReference._locationChangingHashChangeSideEffects = []; + dotNetObjectReference._locationChangingInvalidateSideEffects = []; window.location.href = href; if (historyIndex === null || historyIndex === undefined) { window.history.state = null; @@ -561,12 +854,68 @@ function __setLocationAndHistory(href, historyIndex, userState) { }; } __syncLocation(); + window.history._entries = [{ + href: window.location.href, + state: window.history.state + }]; + window.history._entryIndex = 0; } function __setCurrentHistoryIndex(value) { hashRoutingState.currentHistoryIndex = Number(value); } +function __enqueueLocationChangingResult(value) { + dotNetObjectReference._locationChangingResults.push(Boolean(value)); +} + +function __enqueueLocationChangingHistoryGoSideEffect(delta) { + dotNetObjectReference._locationChangingHistoryGoSideEffects.push(Number(delta)); +} + +function __enqueueLocationChangingHashChangeSideEffect(href, historyIndex, userState) { + dotNetObjectReference._locationChangingHashChangeSideEffects.push({ + href: href, + historyIndex: Number(historyIndex), + userState: userState === undefined ? null : userState + }); +} + +function __enqueueLocationChangingInvalidateSideEffect(href, historyIndex, userState) { + dotNetObjectReference._locationChangingInvalidateSideEffects.push({ + href: href, + historyIndex: Number(historyIndex), + userState: userState === undefined ? null : userState + }); +} + +function __historyGo(delta) { + return window.history.go(delta); +} + +function __hashChangeTo(href, historyIndex, userState) { + __setLocationAndHistoryEntry(href, historyIndex, userState); + return __dispatchWindowEvent("hashchange", {}); +} + +function __setLocationAndHistoryEntry(href, historyIndex, userState) { + window.location.href = href; + window.history.state = { + _qhrIndex: Number(historyIndex), + userState: userState === undefined ? null : userState + }; + __syncLocation(); +} + +function __dispatchWindowEvent(name, event) { + const handler = window._events[name]; + if (handler) { + return handler(event); + } + + return null; +} + function __syncLocation() { const absolute = new URL(window.location.href, document.baseURI); window.location.href = absolute.href; @@ -752,5 +1101,29 @@ private sealed class BrowserHistoryState [JsonPropertyName("userState")] public string? UserState { get; init; } } + + private sealed class BrowserLocationChangingCall + { + [JsonPropertyName("location")] + public string? Location { get; init; } + + [JsonPropertyName("historyEntryState")] + public string? HistoryEntryState { get; init; } + + [JsonPropertyName("isNavigationIntercepted")] + public bool IsNavigationIntercepted { get; init; } + } + + private sealed class BrowserLocationChangedCall + { + [JsonPropertyName("location")] + public string? Location { get; init; } + + [JsonPropertyName("historyEntryState")] + public string? HistoryEntryState { get; init; } + + [JsonPropertyName("isNavigationIntercepted")] + public bool IsNavigationIntercepted { get; init; } + } } } From 6e3728e6eb5ec71d1c138ee4ec6f90ce8819572c Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Wed, 6 May 2026 16:44:59 +0100 Subject: [PATCH 2/5] Review feedback --- .../wwwroot/hash-routing.module.js | 53 +++++-- .../HashRoutingJavaScriptBehaviorTests.cs | 130 ++++++++++++++++-- 2 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index 7523a44..3ad4ad4 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -29,6 +29,8 @@ const hashRoutingState = { lastProcessedBrowserNavigationKey: "", }; +const suppressedBrowserNavigationTimeoutMilliseconds = 100; + export function initialize(dotNetObjectReference, options, baseUri, currentPathUri) { hashRoutingState.dotNetObjectReference = dotNetObjectReference; hashRoutingState.options = normalizeOptions(options); @@ -329,9 +331,9 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { hashRoutingState.pendingBrowserNavigation = browserNavigation; try { - if (canRevertWithHistory) { - await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta); - } + const revertedWithHistory = canRevertWithHistory + ? await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta) + : false; if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { return; @@ -353,7 +355,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { return; } - if (canRevertWithHistory) { + if (revertedWithHistory) { await navigateHistoryWithoutBrowserNavigationEvents(historyDelta); } @@ -448,28 +450,59 @@ function createBrowserNavigation(rawLocation) { } function completeSuppressedBrowserNavigationEvent(navigationKey) { - const resolver = hashRoutingState.suppressedBrowserNavigationEventResolvers.shift(); - if (!resolver) { + const resolverEntry = hashRoutingState.suppressedBrowserNavigationEventResolvers.shift(); + if (!resolverEntry) { return false; } + if (resolverEntry.timeoutId !== null) { + clearTimeout(resolverEntry.timeoutId); + } + hashRoutingState.lastSuppressedBrowserNavigationKey = navigationKey; - resolver(); + resolverEntry.resolve(true); return true; } function navigateHistoryWithoutBrowserNavigationEvents(delta) { if (delta === 0 || typeof window.history.go !== "function") { - return Promise.resolve(); + return Promise.resolve(false); } return new Promise(function (resolve) { - hashRoutingState.suppressedBrowserNavigationEventResolvers.push(resolve); - window.history.go(delta); + const resolverEntry = { + resolve, + timeoutId: null, + }; + + resolverEntry.timeoutId = setTimeout(function () { + removeSuppressedBrowserNavigationEventResolver(resolverEntry); + resolve(false); + }, suppressedBrowserNavigationTimeoutMilliseconds); + + hashRoutingState.suppressedBrowserNavigationEventResolvers.push(resolverEntry); + + try { + window.history.go(delta); + } catch { + if (resolverEntry.timeoutId !== null) { + clearTimeout(resolverEntry.timeoutId); + } + + removeSuppressedBrowserNavigationEventResolver(resolverEntry); + resolve(false); + } }); } +function removeSuppressedBrowserNavigationEventResolver(resolverEntry) { + const resolverIndex = hashRoutingState.suppressedBrowserNavigationEventResolvers.indexOf(resolverEntry); + if (resolverIndex >= 0) { + hashRoutingState.suppressedBrowserNavigationEventResolvers.splice(resolverIndex, 1); + } +} + async function canContinueNavigation(pathAbsoluteUri, historyEntryState, interceptedLink) { if (!hashRoutingState.navigationLockEnabled) { return true; diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 1b7a6bf..9e0be19 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -301,6 +301,44 @@ public void GIVEN_InternalAnchorClicked_WHEN_LinkNavigationAllowed_THEN_LinkNavi }); } + [Fact] + public void GIVEN_HistoryGoThrowsDuringSuppressedNavigation_WHEN_BrowserNavigationAllowed_THEN_NavigationStillCompletes() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(true); + _target.ThrowSuppressedHistoryGo(); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/first"); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/first"); + } + + [Fact] + public void GIVEN_HistoryGoIsIgnoredDuringSuppressedNavigation_WHEN_TimeoutRuns_THEN_NextBrowserNavigationIsNotSuppressed() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(true); + _target.IgnoreSuppressedHistoryGo(); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/first"); + + _target.HistoryGo(1); + _target.ProcessTasks(); + + _target.GetLocationChangedCalls().Last().Location.Should().Be("http://localhost/second"); + } + private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -442,10 +480,21 @@ public void HashChangeTo(string href, int historyIndex, string? userState) _engine.Invoke("__hashChangeTo", href, historyIndex, userState ?? JsValue.Null); } + public void ThrowSuppressedHistoryGo() + { + _engine.Invoke("__throwHistoryGoAfter", 1); + } + + public void IgnoreSuppressedHistoryGo() + { + _engine.Invoke("__ignoreHistoryGoAfter", 1); + } + public void ProcessTasks() { for (var index = 0; index < 20; index++) { + _engine.Invoke("__runTimers"); _engine.Advanced.ProcessTasks(); } } @@ -654,6 +703,28 @@ function MutationObserver(callback) { } }; +let nextTimerId = 0; +let timers = []; + +function setTimeout(callback) { + const timer = { + id: ++nextTimerId, + callback: callback, + active: true + }; + timers.push(timer); + + return timer.id; +} + +function clearTimeout(timerId) { + for (const timer of timers) { + if (timer.id === timerId) { + timer.active = false; + } + } +} + const document = { baseURI: "http://localhost/", _events: {}, @@ -684,6 +755,8 @@ function MutationObserver(callback) { _lastReplacedHref: "", _lastReloadedHref: "", _reloadCount: 0, + _throwHistoryGoAfter: null, + _ignoreHistoryGoAfter: null, location: { href: "http://localhost/#/", hash: "#/", @@ -732,6 +805,25 @@ function MutationObserver(callback) { __syncLocation(); }, go: function(delta) { + if (window._throwHistoryGoAfter !== null) { + if (window._throwHistoryGoAfter <= 0) { + window._throwHistoryGoAfter = null; + throw new Error("history.go failed"); + } + + window._throwHistoryGoAfter--; + } + + if (window._ignoreHistoryGoAfter !== null) { + if (window._ignoreHistoryGoAfter <= 0) { + window._ignoreHistoryGoAfter = null; + __runTimers(); + return; + } + + window._ignoreHistoryGoAfter--; + } + const nextIndex = this._entryIndex + Number(delta); if (nextIndex < 0 || nextIndex >= this._entries.length) { return; @@ -743,15 +835,13 @@ function MutationObserver(callback) { this.state = entry.state; window.location.href = entry.href; __syncLocation(); - const popStateResult = __dispatchWindowEvent("popstate", { + __dispatchWindowEvent("popstate", { state: this.state }); if (window.location.hash !== previousHash) { - return __dispatchWindowEvent("hashchange", {}) || popStateResult; + __dispatchWindowEvent("hashchange", {}); } - - return popStateResult; } }, addEventListener: function(name, handler) { @@ -820,7 +910,7 @@ function __clickAnchor(index) { return; } - return handler({ + handler({ defaultPrevented: false, button: 0, metaKey: false, @@ -844,6 +934,9 @@ function __setLocationAndHistory(href, historyIndex, userState) { dotNetObjectReference._locationChangingHistoryGoSideEffects = []; dotNetObjectReference._locationChangingHashChangeSideEffects = []; dotNetObjectReference._locationChangingInvalidateSideEffects = []; + timers = []; + window._throwHistoryGoAfter = null; + window._ignoreHistoryGoAfter = null; window.location.href = href; if (historyIndex === null || historyIndex === undefined) { window.history.state = null; @@ -890,12 +983,31 @@ function __enqueueLocationChangingInvalidateSideEffect(href, historyIndex, userS } function __historyGo(delta) { - return window.history.go(delta); + window.history.go(delta); } function __hashChangeTo(href, historyIndex, userState) { __setLocationAndHistoryEntry(href, historyIndex, userState); - return __dispatchWindowEvent("hashchange", {}); + __dispatchWindowEvent("hashchange", {}); +} + +function __throwHistoryGoAfter(callCount) { + window._throwHistoryGoAfter = Number(callCount); +} + +function __ignoreHistoryGoAfter(callCount) { + window._ignoreHistoryGoAfter = Number(callCount); +} + +function __runTimers() { + const scheduledTimers = timers; + timers = []; + + for (const timer of scheduledTimers) { + if (timer.active) { + timer.callback(); + } + } } function __setLocationAndHistoryEntry(href, historyIndex, userState) { @@ -910,10 +1022,8 @@ function __setLocationAndHistoryEntry(href, historyIndex, userState) { function __dispatchWindowEvent(name, event) { const handler = window._events[name]; if (handler) { - return handler(event); + handler(event); } - - return null; } function __syncLocation() { From 781de7b4f5f5e95fe41ae8183aa4730530137172 Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Wed, 6 May 2026 19:03:47 +0100 Subject: [PATCH 3/5] Review feedback --- .../wwwroot/hash-routing.module.js | 15 +++-- .../HashRoutingJavaScriptBehaviorTests.cs | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index 3ad4ad4..4f5e801 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -323,6 +323,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { const browserNavigationId = ++hashRoutingState.currentBrowserNavigationId; const previousHistoryIndex = hashRoutingState.currentHistoryIndex; const historyDelta = browserNavigation.historyIndex - previousHistoryIndex; + const revertNavigationKey = createBrowserNavigationKey(hashRoutingState.lastAcceptedHashAbsoluteUri, previousHistoryIndex, hashRoutingState.lastAcceptedHistoryState); const canRevertWithHistory = hashRoutingState.navigationLockEnabled && historyDelta !== 0 && typeof window.history.go === "function"; @@ -332,7 +333,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { try { const revertedWithHistory = canRevertWithHistory - ? await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta) + ? await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta, revertNavigationKey) : false; if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { @@ -347,7 +348,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { } if (!shouldContinue) { - if (!canRevertWithHistory && hashRoutingState.lastAcceptedHashAbsoluteUri) { + if (!revertedWithHistory && hashRoutingState.lastAcceptedHashAbsoluteUri) { const rollbackState = withHistoryMetadata(window.history.state, hashRoutingState.lastAcceptedHistoryState, hashRoutingState.currentHistoryIndex); window.history.replaceState(rollbackState, "", hashRoutingState.lastAcceptedHashAbsoluteUri); } @@ -356,7 +357,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { } if (revertedWithHistory) { - await navigateHistoryWithoutBrowserNavigationEvents(historyDelta); + await navigateHistoryWithoutBrowserNavigationEvents(historyDelta, browserNavigation.navigationKey); } if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { @@ -450,7 +451,9 @@ function createBrowserNavigation(rawLocation) { } function completeSuppressedBrowserNavigationEvent(navigationKey) { - const resolverEntry = hashRoutingState.suppressedBrowserNavigationEventResolvers.shift(); + const resolverEntry = hashRoutingState.suppressedBrowserNavigationEventResolvers.find(function (entry) { + return entry.navigationKey === navigationKey; + }); if (!resolverEntry) { return false; } @@ -459,19 +462,21 @@ function completeSuppressedBrowserNavigationEvent(navigationKey) { clearTimeout(resolverEntry.timeoutId); } + removeSuppressedBrowserNavigationEventResolver(resolverEntry); hashRoutingState.lastSuppressedBrowserNavigationKey = navigationKey; resolverEntry.resolve(true); return true; } -function navigateHistoryWithoutBrowserNavigationEvents(delta) { +function navigateHistoryWithoutBrowserNavigationEvents(delta, navigationKey) { if (delta === 0 || typeof window.history.go !== "function") { return Promise.resolve(false); } return new Promise(function (resolve) { const resolverEntry = { + navigationKey, resolve, timeoutId: null, }; diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 9e0be19..336586a 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -339,6 +339,37 @@ public void GIVEN_HistoryGoIsIgnoredDuringSuppressedNavigation_WHEN_TimeoutRuns_ _target.GetLocationChangedCalls().Last().Location.Should().Be("http://localhost/second"); } + [Fact] + public void GIVEN_HistoryRevertIsIgnoredAndNavigationDenied_WHEN_LockCheckCompletes_THEN_BrowserRollsBackToLastAcceptedUrl() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(false); + _target.IgnoreSuppressedHistoryGo(); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Should().BeEmpty(); + } + + [Fact] + public void GIVEN_UnrelatedBrowserNavigationArrivesDuringSuppressedWait_WHEN_NavigationKeysDoNotMatch_THEN_UnrelatedNavigationIsProcessed() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.EnqueueSuppressedBrowserNavigationResolver("http://localhost/#/expected", 99, "expected"); + + _target.HashChangeTo("http://localhost/#/third", 3, "third"); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/third"); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/third"); + _target.GetSuppressedBrowserNavigationResolverCount().Should().Be(1); + } + private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -480,6 +511,16 @@ public void HashChangeTo(string href, int historyIndex, string? userState) _engine.Invoke("__hashChangeTo", href, historyIndex, userState ?? JsValue.Null); } + public void EnqueueSuppressedBrowserNavigationResolver(string href, int historyIndex, string? userState) + { + _engine.Invoke("__enqueueSuppressedBrowserNavigationResolver", href, historyIndex, userState ?? JsValue.Null); + } + + public int GetSuppressedBrowserNavigationResolverCount() + { + return (int)_engine.Invoke("__getSuppressedBrowserNavigationResolverCount").AsNumber(); + } + public void ThrowSuppressedHistoryGo() { _engine.Invoke("__throwHistoryGoAfter", 1); @@ -991,6 +1032,22 @@ function __hashChangeTo(href, historyIndex, userState) { __dispatchWindowEvent("hashchange", {}); } +function __enqueueSuppressedBrowserNavigationResolver(href, historyIndex, userState) { + const historyEntryState = userState === undefined ? null : userState; + const targetPathAbsoluteUri = toPathAbsoluteUri(href, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + const targetHashAbsoluteUri = toHashAbsoluteUri(targetPathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + hashRoutingState.suppressedBrowserNavigationEventResolvers.push({ + navigationKey: createBrowserNavigationKey(targetHashAbsoluteUri, Number(historyIndex), historyEntryState), + resolve: function() { + }, + timeoutId: null + }); +} + +function __getSuppressedBrowserNavigationResolverCount() { + return hashRoutingState.suppressedBrowserNavigationEventResolvers.length; +} + function __throwHistoryGoAfter(callCount) { window._throwHistoryGoAfter = Number(callCount); } From 2a20c8d3ea6fd5bd8e9e5b62407c048cf706e21a Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Wed, 6 May 2026 20:49:16 +0100 Subject: [PATCH 4/5] Review feedback --- .../wwwroot/hash-routing.module.js | 5 +- .../HashRoutingJavaScriptBehaviorTests.cs | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index 4f5e801..930da72 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -357,7 +357,10 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { } if (revertedWithHistory) { - await navigateHistoryWithoutBrowserNavigationEvents(historyDelta, browserNavigation.navigationKey); + const reappliedWithHistory = await navigateHistoryWithoutBrowserNavigationEvents(historyDelta, browserNavigation.navigationKey); + if (!reappliedWithHistory) { + return; + } } if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 336586a..806e5b1 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -370,6 +370,50 @@ public void GIVEN_UnrelatedBrowserNavigationArrivesDuringSuppressedWait_WHEN_Nav _target.GetSuppressedBrowserNavigationResolverCount().Should().Be(1); } + [Fact] + public void GIVEN_HistoryReapplyThrowsAfterAllowedNavigation_WHEN_LockCheckCompletes_THEN_RevertedHistoryEntryIsNotRewritten() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(true); + _target.ThrowReapplyHistoryGo(); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState + { + HistoryIndex = 2, + UserState = "second" + }); + _target.GetLocationChangedCalls().Should().BeEmpty(); + } + + [Fact] + public void GIVEN_HistoryReapplyIsIgnoredAfterAllowedNavigation_WHEN_TimeoutRuns_THEN_RevertedHistoryEntryIsNotRewritten() + { + _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); + _target.NavigateTo("http://localhost/first", false, "first"); + _target.NavigateTo("http://localhost/second", false, "second"); + _target.SetNavigationLockState(true); + _target.EnqueueLocationChangingResult(true); + _target.IgnoreReapplyHistoryGo(); + + _target.HistoryGo(-1); + _target.ProcessTasks(); + + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState + { + HistoryIndex = 2, + UserState = "second" + }); + _target.GetLocationChangedCalls().Should().BeEmpty(); + } + private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -531,6 +575,16 @@ public void IgnoreSuppressedHistoryGo() _engine.Invoke("__ignoreHistoryGoAfter", 1); } + public void ThrowReapplyHistoryGo() + { + _engine.Invoke("__throwHistoryGoAfter", 2); + } + + public void IgnoreReapplyHistoryGo() + { + _engine.Invoke("__ignoreHistoryGoAfter", 2); + } + public void ProcessTasks() { for (var index = 0; index < 20; index++) From edeede4e8b76ba6690041db72a4c5fcc8409ad0e Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Wed, 6 May 2026 21:34:45 +0100 Subject: [PATCH 5/5] Updated logic to mirror MS blazor path routing --- .../wwwroot/hash-routing.module.js | 44 ++---- .../HashRoutingJavaScriptBehaviorTests.cs | 140 +++--------------- 2 files changed, 31 insertions(+), 153 deletions(-) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index 930da72..6d8bcea 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -29,8 +29,6 @@ const hashRoutingState = { lastProcessedBrowserNavigationKey: "", }; -const suppressedBrowserNavigationTimeoutMilliseconds = 100; - export function initialize(dotNetObjectReference, options, baseUri, currentPathUri) { hashRoutingState.dotNetObjectReference = dotNetObjectReference; hashRoutingState.options = normalizeOptions(options); @@ -332,9 +330,9 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { hashRoutingState.pendingBrowserNavigation = browserNavigation; try { - const revertedWithHistory = canRevertWithHistory - ? await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta, revertNavigationKey) - : false; + if (canRevertWithHistory) { + await navigateHistoryWithoutBrowserNavigationEvents(-historyDelta, revertNavigationKey); + } if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { return; @@ -348,7 +346,7 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { } if (!shouldContinue) { - if (!revertedWithHistory && hashRoutingState.lastAcceptedHashAbsoluteUri) { + if (!canRevertWithHistory && hashRoutingState.lastAcceptedHashAbsoluteUri) { const rollbackState = withHistoryMetadata(window.history.state, hashRoutingState.lastAcceptedHistoryState, hashRoutingState.currentHistoryIndex); window.history.replaceState(rollbackState, "", hashRoutingState.lastAcceptedHashAbsoluteUri); } @@ -356,11 +354,8 @@ async function processBrowserNavigation(rawLocation, interceptedLink) { return; } - if (revertedWithHistory) { - const reappliedWithHistory = await navigateHistoryWithoutBrowserNavigationEvents(historyDelta, browserNavigation.navigationKey); - if (!reappliedWithHistory) { - return; - } + if (canRevertWithHistory) { + await navigateHistoryWithoutBrowserNavigationEvents(historyDelta, browserNavigation.navigationKey); } if (browserNavigationId !== hashRoutingState.currentBrowserNavigationId) { @@ -423,6 +418,7 @@ function ignorePendingBrowserNavigation() { hashRoutingState.currentBrowserNavigationId++; hashRoutingState.activeBrowserNavigationKey = ""; hashRoutingState.pendingBrowserNavigation = null; + hashRoutingState.lastSuppressedBrowserNavigationKey = ""; } function createBrowserNavigation(rawLocation) { @@ -461,46 +457,26 @@ function completeSuppressedBrowserNavigationEvent(navigationKey) { return false; } - if (resolverEntry.timeoutId !== null) { - clearTimeout(resolverEntry.timeoutId); - } - removeSuppressedBrowserNavigationEventResolver(resolverEntry); hashRoutingState.lastSuppressedBrowserNavigationKey = navigationKey; - resolverEntry.resolve(true); + resolverEntry.resolve(); return true; } function navigateHistoryWithoutBrowserNavigationEvents(delta, navigationKey) { if (delta === 0 || typeof window.history.go !== "function") { - return Promise.resolve(false); + return Promise.resolve(); } return new Promise(function (resolve) { const resolverEntry = { navigationKey, resolve, - timeoutId: null, }; - resolverEntry.timeoutId = setTimeout(function () { - removeSuppressedBrowserNavigationEventResolver(resolverEntry); - resolve(false); - }, suppressedBrowserNavigationTimeoutMilliseconds); - hashRoutingState.suppressedBrowserNavigationEventResolvers.push(resolverEntry); - - try { - window.history.go(delta); - } catch { - if (resolverEntry.timeoutId !== null) { - clearTimeout(resolverEntry.timeoutId); - } - - removeSuppressedBrowserNavigationEventResolver(resolverEntry); - resolve(false); - } + window.history.go(delta); }); } diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 806e5b1..89e049d 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -279,81 +279,48 @@ public void GIVEN_LatestBrowserNavigationDenied_WHEN_LockCheckCompletes_THEN_Bro } [Fact] - public void GIVEN_InternalAnchorClicked_WHEN_LinkNavigationAllowed_THEN_LinkNavigationStillPushesHistoryAndNotifiesOnce() - { - var anchorIndex = _target.AppendAnchor("settings"); - _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); - - _target.ClickAnchor(anchorIndex); - _target.ProcessTasks(); - - _target.GetLocationHref().Should().Be("http://localhost/#/settings"); - _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState - { - HistoryIndex = 1, - UserState = null - }); - _target.GetLocationChangedCalls().Should().ContainSingle().Which.Should().BeEquivalentTo(new BrowserLocationChangedCall - { - Location = "http://localhost/settings", - HistoryEntryState = null, - IsNavigationIntercepted = true - }); - } - - [Fact] - public void GIVEN_HistoryGoThrowsDuringSuppressedNavigation_WHEN_BrowserNavigationAllowed_THEN_NavigationStillCompletes() + public void GIVEN_DeniedBrowserNavigationThenProgrammaticNavigation_WHEN_ReturningToSuppressedEntry_THEN_BlazorIsNotified() { _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); _target.NavigateTo("http://localhost/first", false, "first"); _target.NavigateTo("http://localhost/second", false, "second"); _target.SetNavigationLockState(true); - _target.EnqueueLocationChangingResult(true); - _target.ThrowSuppressedHistoryGo(); + _target.EnqueueLocationChangingResult(false); _target.HistoryGo(-1); _target.ProcessTasks(); - _target.GetLocationHref().Should().Be("http://localhost/#/first"); - _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/first"); - } - - [Fact] - public void GIVEN_HistoryGoIsIgnoredDuringSuppressedNavigation_WHEN_TimeoutRuns_THEN_NextBrowserNavigationIsNotSuppressed() - { - _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); - _target.NavigateTo("http://localhost/first", false, "first"); - _target.NavigateTo("http://localhost/second", false, "second"); - _target.SetNavigationLockState(true); + _target.NavigateTo("http://localhost/third", false, "third"); _target.EnqueueLocationChangingResult(true); - _target.IgnoreSuppressedHistoryGo(); _target.HistoryGo(-1); _target.ProcessTasks(); - _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/first"); - - _target.HistoryGo(1); - _target.ProcessTasks(); - - _target.GetLocationChangedCalls().Last().Location.Should().Be("http://localhost/second"); + _target.GetLocationHref().Should().Be("http://localhost/#/second"); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Location.Should().Be("http://localhost/second"); } [Fact] - public void GIVEN_HistoryRevertIsIgnoredAndNavigationDenied_WHEN_LockCheckCompletes_THEN_BrowserRollsBackToLastAcceptedUrl() + public void GIVEN_InternalAnchorClicked_WHEN_LinkNavigationAllowed_THEN_LinkNavigationStillPushesHistoryAndNotifiesOnce() { + var anchorIndex = _target.AppendAnchor("settings"); _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); - _target.NavigateTo("http://localhost/first", false, "first"); - _target.NavigateTo("http://localhost/second", false, "second"); - _target.SetNavigationLockState(true); - _target.EnqueueLocationChangingResult(false); - _target.IgnoreSuppressedHistoryGo(); - _target.HistoryGo(-1); + _target.ClickAnchor(anchorIndex); _target.ProcessTasks(); - _target.GetLocationHref().Should().Be("http://localhost/#/second"); - _target.GetLocationChangedCalls().Should().BeEmpty(); + _target.GetLocationHref().Should().Be("http://localhost/#/settings"); + _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState + { + HistoryIndex = 1, + UserState = null + }); + _target.GetLocationChangedCalls().Should().ContainSingle().Which.Should().BeEquivalentTo(new BrowserLocationChangedCall + { + Location = "http://localhost/settings", + HistoryEntryState = null, + IsNavigationIntercepted = true + }); } [Fact] @@ -370,50 +337,6 @@ public void GIVEN_UnrelatedBrowserNavigationArrivesDuringSuppressedWait_WHEN_Nav _target.GetSuppressedBrowserNavigationResolverCount().Should().Be(1); } - [Fact] - public void GIVEN_HistoryReapplyThrowsAfterAllowedNavigation_WHEN_LockCheckCompletes_THEN_RevertedHistoryEntryIsNotRewritten() - { - _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); - _target.NavigateTo("http://localhost/first", false, "first"); - _target.NavigateTo("http://localhost/second", false, "second"); - _target.SetNavigationLockState(true); - _target.EnqueueLocationChangingResult(true); - _target.ThrowReapplyHistoryGo(); - - _target.HistoryGo(-1); - _target.ProcessTasks(); - - _target.GetLocationHref().Should().Be("http://localhost/#/second"); - _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState - { - HistoryIndex = 2, - UserState = "second" - }); - _target.GetLocationChangedCalls().Should().BeEmpty(); - } - - [Fact] - public void GIVEN_HistoryReapplyIsIgnoredAfterAllowedNavigation_WHEN_TimeoutRuns_THEN_RevertedHistoryEntryIsNotRewritten() - { - _target.Initialize("http://localhost/", "http://localhost/#/", "http://localhost/"); - _target.NavigateTo("http://localhost/first", false, "first"); - _target.NavigateTo("http://localhost/second", false, "second"); - _target.SetNavigationLockState(true); - _target.EnqueueLocationChangingResult(true); - _target.IgnoreReapplyHistoryGo(); - - _target.HistoryGo(-1); - _target.ProcessTasks(); - - _target.GetLocationHref().Should().Be("http://localhost/#/second"); - _target.GetHistoryState().Should().BeEquivalentTo(new BrowserHistoryState - { - HistoryIndex = 2, - UserState = "second" - }); - _target.GetLocationChangedCalls().Should().BeEmpty(); - } - private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -565,26 +488,6 @@ public int GetSuppressedBrowserNavigationResolverCount() return (int)_engine.Invoke("__getSuppressedBrowserNavigationResolverCount").AsNumber(); } - public void ThrowSuppressedHistoryGo() - { - _engine.Invoke("__throwHistoryGoAfter", 1); - } - - public void IgnoreSuppressedHistoryGo() - { - _engine.Invoke("__ignoreHistoryGoAfter", 1); - } - - public void ThrowReapplyHistoryGo() - { - _engine.Invoke("__throwHistoryGoAfter", 2); - } - - public void IgnoreReapplyHistoryGo() - { - _engine.Invoke("__ignoreHistoryGoAfter", 2); - } - public void ProcessTasks() { for (var index = 0; index < 20; index++) @@ -1093,8 +996,7 @@ function __enqueueSuppressedBrowserNavigationResolver(href, historyIndex, userSt hashRoutingState.suppressedBrowserNavigationEventResolvers.push({ navigationKey: createBrowserNavigationKey(targetHashAbsoluteUri, Number(historyIndex), historyEntryState), resolve: function() { - }, - timeoutId: null + } }); }