Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/Blazor.HashRouting/wwwroot/hash-routing.module.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const hashRoutingState = {
clickHandler: null,
hashChangeHandler: null,
popStateHandler: null,
anchorMutationObserver: null,
processingBrowserNavigation: false,
lastProcessedBrowserNavigationKey: "",
};
Expand All @@ -37,6 +38,7 @@ export function initialize(dotNetObjectReference, options, baseUri, currentPathU

if (!hashRoutingState.initialized) {
attachHandlers();
startAnchorMonitoring();
hashRoutingState.initialized = true;
}

Expand Down Expand Up @@ -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 = "";
}

Expand Down Expand Up @@ -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;
Comment on lines +237 to +238
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-self anchor targets during href rewrite

rewriteAnchorHref canonicalizes every internal a[href] to a hash URL but does not mirror the existing target guard in the click interceptor (target && target !== "_self"). As a result, links intentionally marked with targets like _blank are rewritten anyway, so browser-managed navigation opens the hash-routed SPA URL instead of the original path URL; this changes the behavior of links that were previously used to opt out of interception.

Useful? React with 👍 / 👎.

}

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;
Expand Down
172 changes: 172 additions & 0 deletions test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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: "",
Expand Down Expand Up @@ -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;
Expand Down
Loading