From 413ba9210b98b3d9c8f7ee0707a3e9b65da60e28 Mon Sep 17 00:00:00 2001 From: ahjephson <16685186+ahjephson@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:07:39 +0000 Subject: [PATCH] Add href mutation --- .../wwwroot/hash-routing.module.js | 97 ++++++++++ .../HashRoutingJavaScriptBehaviorTests.cs | 172 ++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js index f3a690c..eb8c782 100644 --- a/src/Blazor.HashRouting/wwwroot/hash-routing.module.js +++ b/src/Blazor.HashRouting/wwwroot/hash-routing.module.js @@ -19,6 +19,7 @@ const hashRoutingState = { clickHandler: null, hashChangeHandler: null, popStateHandler: null, + anchorMutationObserver: null, processingBrowserNavigation: false, lastProcessedBrowserNavigationKey: "", }; @@ -37,6 +38,7 @@ export function initialize(dotNetObjectReference, options, baseUri, currentPathU if (!hashRoutingState.initialized) { attachHandlers(); + startAnchorMonitoring(); hashRoutingState.initialized = true; } @@ -106,6 +108,10 @@ export function dispose() { hashRoutingState.clickHandler = null; hashRoutingState.hashChangeHandler = null; hashRoutingState.popStateHandler = null; + if (hashRoutingState.anchorMutationObserver) { + hashRoutingState.anchorMutationObserver.disconnect(); + } + hashRoutingState.anchorMutationObserver = null; hashRoutingState.lastProcessedBrowserNavigationKey = ""; } @@ -166,6 +172,97 @@ function attachHandlers() { window.addEventListener("popstate", hashRoutingState.popStateHandler); } +function startAnchorMonitoring() { + rewriteInternalAnchors(); + + if (typeof MutationObserver !== "function") { + return; + } + + const observationTarget = document.body || document.documentElement || document; + hashRoutingState.anchorMutationObserver = new MutationObserver(function (mutations) { + for (const mutation of mutations) { + if (mutation.type === "attributes") { + rewriteAnchorsForNode(mutation.target); + continue; + } + + if (!mutation.addedNodes) { + continue; + } + + for (const addedNode of mutation.addedNodes) { + rewriteAnchorsForNode(addedNode); + } + } + }); + + hashRoutingState.anchorMutationObserver.observe(observationTarget, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["href"], + }); +} + +function rewriteInternalAnchors() { + if (typeof document.querySelectorAll !== "function") { + return; + } + + for (const anchor of document.querySelectorAll("a[href]")) { + rewriteAnchorHref(anchor); + } +} + +function rewriteAnchorsForNode(node) { + if (!(node instanceof Element)) { + return; + } + + if (typeof node.matches === "function" && node.matches("a[href]")) { + rewriteAnchorHref(node); + } + + if (typeof node.querySelectorAll !== "function") { + return; + } + + for (const anchor of node.querySelectorAll("a[href]")) { + rewriteAnchorHref(anchor); + } +} + +function rewriteAnchorHref(anchor) { + if (!hashRoutingState.options.interceptInternalLinks || anchor.hasAttribute("download")) { + return; + } + + let absoluteHrefUrl; + try { + absoluteHrefUrl = new URL(anchor.href, hashRoutingState.baseUri || document.baseURI); + } catch { + return; + } + + if (!isCanonicalizableHash(absoluteHrefUrl.hash, hashRoutingState.normalizedHashPrefix)) { + return; + } + + if (!isWithinBaseUriSpace(absoluteHrefUrl)) { + return; + } + + const pathAbsoluteUri = toPathAbsoluteUriFromAbsolute(absoluteHrefUrl, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + const hashAbsoluteUri = toHashAbsoluteUri(pathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix); + + if (sameUri(absoluteHrefUrl.href, hashAbsoluteUri)) { + return; + } + + anchor.setAttribute("href", hashAbsoluteUri); +} + async function processBrowserNavigation(rawLocation, interceptedLink) { if (hashRoutingState.processingBrowserNavigation) { return; diff --git a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs index 6abe5ce..ac22390 100644 --- a/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs +++ b/test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs @@ -98,6 +98,51 @@ public void GIVEN_ReplaceExternalNavigation_WHEN_NavigateExternallyCalled_THEN_W _target.GetLastReplacedHref().Should().Be("https://example.com/path?query=value"); } + [Fact] + public void GIVEN_InternalAnchorPresentBeforeInitialization_WHEN_InitializeCalled_THEN_AnchorHrefIsCanonicalizedToHashRoute() + { + var anchorIndex = _target.AppendAnchor("details/ABC?tab=Peers"); + + _target.Initialize( + "http://localhost/", + "http://localhost/#/", + "http://localhost/"); + + _target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/#/details/ABC?tab=Peers"); + } + + [Fact] + public void GIVEN_InternalAnchorAddedAfterInitialization_WHEN_AnchorObserved_THEN_AnchorHrefIsCanonicalizedToHashRoute() + { + _target.Initialize( + "http://localhost/proxy/app/", + "http://localhost/proxy/app/#/", + "http://localhost/proxy/app/"); + + var anchorIndex = _target.AppendAnchor("settings"); + + _target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/proxy/app/#/settings"); + } + + [Fact] + public void GIVEN_InternalAnchor_WHEN_InitializeCalledWithLinkInterceptionDisabled_THEN_AnchorHrefRemainsPathRoute() + { + var anchorIndex = _target.AppendAnchor("settings"); + + _target.Initialize( + "http://localhost/", + "http://localhost/#/", + "http://localhost/", + new + { + canonicalizeToHash = true, + hashPrefix = "/", + interceptInternalLinks = false + }); + + _target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/settings"); + } + private sealed class HashRoutingJavaScriptTestHost { private readonly Engine _engine; @@ -131,6 +176,20 @@ public string GetLocationHref() return _engine.Invoke("__getLocationHref").AsString(); } + public int AppendAnchor(string href, string? target = null, bool download = false) + { + var targetValue = target is null + ? JsValue.Null + : JsValue.FromObject(_engine, target); + + return (int)_engine.Invoke("__appendAnchor", href, targetValue, download).AsNumber(); + } + + public string GetAnchorHref(int index) + { + return _engine.Invoke("__getAnchorHref", index).AsString(); + } + public string Initialize(string baseUri, string locationHref, string currentPathUri, object? options = null) { _engine.Invoke("__setDocumentBaseUri", baseUri); @@ -229,6 +288,86 @@ function URL(raw, base) { function Element() { } +Element.prototype.closest = function(selector) { + return this.matches(selector) ? this : null; +}; + +Element.prototype.matches = function() { + return false; +}; + +Element.prototype.querySelectorAll = function() { + return []; +}; + +function AnchorElement(href, target, download) { + this._attributes = {}; + + if (href !== null && href !== undefined) { + this._attributes.href = String(href); + } + + if (target !== null && target !== undefined) { + this._attributes.target = String(target); + } + + if (download) { + this._attributes.download = ""; + } +} + +AnchorElement.prototype = Object.create(Element.prototype); +AnchorElement.prototype.constructor = AnchorElement; + +AnchorElement.prototype.getAttribute = function(name) { + return Object.prototype.hasOwnProperty.call(this._attributes, name) + ? this._attributes[name] + : null; +}; + +AnchorElement.prototype.setAttribute = function(name, value) { + this._attributes[name] = String(value); + + if (document._mutationObserver && name === "href") { + document._mutationObserver._callback([{ + type: "attributes", + target: this, + attributeName: "href" + }]); + } +}; + +AnchorElement.prototype.hasAttribute = function(name) { + return Object.prototype.hasOwnProperty.call(this._attributes, name); +}; + +AnchorElement.prototype.matches = function(selector) { + return selector === "a[href]" && this.hasAttribute("href"); +}; + +Object.defineProperty(AnchorElement.prototype, "href", { + get: function() { + return new URL(this.getAttribute("href") || "", document.baseURI).href; + }, + set: function(value) { + this.setAttribute("href", value); + } +}); + +function MutationObserver(callback) { + this._callback = callback; +} + +MutationObserver.prototype.observe = function() { + document._mutationObserver = this; +}; + +MutationObserver.prototype.disconnect = function() { + if (document._mutationObserver === this) { + document._mutationObserver = null; + } +}; + const dotNetObjectReference = { invokeMethodAsync: function() { return null; @@ -238,14 +377,28 @@ function Element() { const document = { baseURI: "http://localhost/", _events: {}, + _anchors: [], + _mutationObserver: null, addEventListener: function(name, handler) { this._events[name] = handler; }, removeEventListener: function(name) { delete this._events[name]; + }, + querySelectorAll: function(selector) { + if (selector !== "a[href]") { + return []; + } + + return this._anchors.filter(function(anchor) { + return anchor.matches(selector); + }); } }; +document.body = document; +document.documentElement = document; + const window = { _events: {}, _lastReplacedHref: "", @@ -295,6 +448,25 @@ function __setDocumentBaseUri(value) { document.baseURI = value; } +function __appendAnchor(href, target, download) { + const anchor = new AnchorElement(href, target, Boolean(download)); + document._anchors.push(anchor); + + if (document._mutationObserver) { + document._mutationObserver._callback([{ + type: "childList", + target: document.body, + addedNodes: [anchor] + }]); + } + + return document._anchors.length - 1; +} + +function __getAnchorHref(index) { + return document._anchors[index].href; +} + function __setLocationAndHistory(href, historyIndex, userState) { window._lastReplacedHref = ""; window.location.href = href;