Skip to content

Commit 7be8470

Browse files
authored
Merge pull request #2 from lantean-code/feature/mutate-hrefs
Add href mutation
2 parents 5b05186 + 413ba92 commit 7be8470

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

src/Blazor.HashRouting/wwwroot/hash-routing.module.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const hashRoutingState = {
1919
clickHandler: null,
2020
hashChangeHandler: null,
2121
popStateHandler: null,
22+
anchorMutationObserver: null,
2223
processingBrowserNavigation: false,
2324
lastProcessedBrowserNavigationKey: "",
2425
};
@@ -37,6 +38,7 @@ export function initialize(dotNetObjectReference, options, baseUri, currentPathU
3738

3839
if (!hashRoutingState.initialized) {
3940
attachHandlers();
41+
startAnchorMonitoring();
4042
hashRoutingState.initialized = true;
4143
}
4244

@@ -106,6 +108,10 @@ export function dispose() {
106108
hashRoutingState.clickHandler = null;
107109
hashRoutingState.hashChangeHandler = null;
108110
hashRoutingState.popStateHandler = null;
111+
if (hashRoutingState.anchorMutationObserver) {
112+
hashRoutingState.anchorMutationObserver.disconnect();
113+
}
114+
hashRoutingState.anchorMutationObserver = null;
109115
hashRoutingState.lastProcessedBrowserNavigationKey = "";
110116
}
111117

@@ -166,6 +172,97 @@ function attachHandlers() {
166172
window.addEventListener("popstate", hashRoutingState.popStateHandler);
167173
}
168174

175+
function startAnchorMonitoring() {
176+
rewriteInternalAnchors();
177+
178+
if (typeof MutationObserver !== "function") {
179+
return;
180+
}
181+
182+
const observationTarget = document.body || document.documentElement || document;
183+
hashRoutingState.anchorMutationObserver = new MutationObserver(function (mutations) {
184+
for (const mutation of mutations) {
185+
if (mutation.type === "attributes") {
186+
rewriteAnchorsForNode(mutation.target);
187+
continue;
188+
}
189+
190+
if (!mutation.addedNodes) {
191+
continue;
192+
}
193+
194+
for (const addedNode of mutation.addedNodes) {
195+
rewriteAnchorsForNode(addedNode);
196+
}
197+
}
198+
});
199+
200+
hashRoutingState.anchorMutationObserver.observe(observationTarget, {
201+
childList: true,
202+
subtree: true,
203+
attributes: true,
204+
attributeFilter: ["href"],
205+
});
206+
}
207+
208+
function rewriteInternalAnchors() {
209+
if (typeof document.querySelectorAll !== "function") {
210+
return;
211+
}
212+
213+
for (const anchor of document.querySelectorAll("a[href]")) {
214+
rewriteAnchorHref(anchor);
215+
}
216+
}
217+
218+
function rewriteAnchorsForNode(node) {
219+
if (!(node instanceof Element)) {
220+
return;
221+
}
222+
223+
if (typeof node.matches === "function" && node.matches("a[href]")) {
224+
rewriteAnchorHref(node);
225+
}
226+
227+
if (typeof node.querySelectorAll !== "function") {
228+
return;
229+
}
230+
231+
for (const anchor of node.querySelectorAll("a[href]")) {
232+
rewriteAnchorHref(anchor);
233+
}
234+
}
235+
236+
function rewriteAnchorHref(anchor) {
237+
if (!hashRoutingState.options.interceptInternalLinks || anchor.hasAttribute("download")) {
238+
return;
239+
}
240+
241+
let absoluteHrefUrl;
242+
try {
243+
absoluteHrefUrl = new URL(anchor.href, hashRoutingState.baseUri || document.baseURI);
244+
} catch {
245+
return;
246+
}
247+
248+
if (!isCanonicalizableHash(absoluteHrefUrl.hash, hashRoutingState.normalizedHashPrefix)) {
249+
return;
250+
}
251+
252+
if (!isWithinBaseUriSpace(absoluteHrefUrl)) {
253+
return;
254+
}
255+
256+
const pathAbsoluteUri = toPathAbsoluteUriFromAbsolute(absoluteHrefUrl, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix);
257+
const hashAbsoluteUri = toHashAbsoluteUri(pathAbsoluteUri, hashRoutingState.baseUri, hashRoutingState.normalizedHashPrefix);
258+
259+
if (sameUri(absoluteHrefUrl.href, hashAbsoluteUri)) {
260+
return;
261+
}
262+
263+
anchor.setAttribute("href", hashAbsoluteUri);
264+
}
265+
169266
async function processBrowserNavigation(rawLocation, interceptedLink) {
170267
if (hashRoutingState.processingBrowserNavigation) {
171268
return;

test/Blazor.HashRouting.Test/HashRoutingJavaScriptBehaviorTests.cs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,51 @@ public void GIVEN_ReplaceExternalNavigation_WHEN_NavigateExternallyCalled_THEN_W
9898
_target.GetLastReplacedHref().Should().Be("https://example.com/path?query=value");
9999
}
100100

101+
[Fact]
102+
public void GIVEN_InternalAnchorPresentBeforeInitialization_WHEN_InitializeCalled_THEN_AnchorHrefIsCanonicalizedToHashRoute()
103+
{
104+
var anchorIndex = _target.AppendAnchor("details/ABC?tab=Peers");
105+
106+
_target.Initialize(
107+
"http://localhost/",
108+
"http://localhost/#/",
109+
"http://localhost/");
110+
111+
_target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/#/details/ABC?tab=Peers");
112+
}
113+
114+
[Fact]
115+
public void GIVEN_InternalAnchorAddedAfterInitialization_WHEN_AnchorObserved_THEN_AnchorHrefIsCanonicalizedToHashRoute()
116+
{
117+
_target.Initialize(
118+
"http://localhost/proxy/app/",
119+
"http://localhost/proxy/app/#/",
120+
"http://localhost/proxy/app/");
121+
122+
var anchorIndex = _target.AppendAnchor("settings");
123+
124+
_target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/proxy/app/#/settings");
125+
}
126+
127+
[Fact]
128+
public void GIVEN_InternalAnchor_WHEN_InitializeCalledWithLinkInterceptionDisabled_THEN_AnchorHrefRemainsPathRoute()
129+
{
130+
var anchorIndex = _target.AppendAnchor("settings");
131+
132+
_target.Initialize(
133+
"http://localhost/",
134+
"http://localhost/#/",
135+
"http://localhost/",
136+
new
137+
{
138+
canonicalizeToHash = true,
139+
hashPrefix = "/",
140+
interceptInternalLinks = false
141+
});
142+
143+
_target.GetAnchorHref(anchorIndex).Should().Be("http://localhost/settings");
144+
}
145+
101146
private sealed class HashRoutingJavaScriptTestHost
102147
{
103148
private readonly Engine _engine;
@@ -131,6 +176,20 @@ public string GetLocationHref()
131176
return _engine.Invoke("__getLocationHref").AsString();
132177
}
133178

179+
public int AppendAnchor(string href, string? target = null, bool download = false)
180+
{
181+
var targetValue = target is null
182+
? JsValue.Null
183+
: JsValue.FromObject(_engine, target);
184+
185+
return (int)_engine.Invoke("__appendAnchor", href, targetValue, download).AsNumber();
186+
}
187+
188+
public string GetAnchorHref(int index)
189+
{
190+
return _engine.Invoke("__getAnchorHref", index).AsString();
191+
}
192+
134193
public string Initialize(string baseUri, string locationHref, string currentPathUri, object? options = null)
135194
{
136195
_engine.Invoke("__setDocumentBaseUri", baseUri);
@@ -229,6 +288,86 @@ function URL(raw, base) {
229288
function Element() {
230289
}
231290
291+
Element.prototype.closest = function(selector) {
292+
return this.matches(selector) ? this : null;
293+
};
294+
295+
Element.prototype.matches = function() {
296+
return false;
297+
};
298+
299+
Element.prototype.querySelectorAll = function() {
300+
return [];
301+
};
302+
303+
function AnchorElement(href, target, download) {
304+
this._attributes = {};
305+
306+
if (href !== null && href !== undefined) {
307+
this._attributes.href = String(href);
308+
}
309+
310+
if (target !== null && target !== undefined) {
311+
this._attributes.target = String(target);
312+
}
313+
314+
if (download) {
315+
this._attributes.download = "";
316+
}
317+
}
318+
319+
AnchorElement.prototype = Object.create(Element.prototype);
320+
AnchorElement.prototype.constructor = AnchorElement;
321+
322+
AnchorElement.prototype.getAttribute = function(name) {
323+
return Object.prototype.hasOwnProperty.call(this._attributes, name)
324+
? this._attributes[name]
325+
: null;
326+
};
327+
328+
AnchorElement.prototype.setAttribute = function(name, value) {
329+
this._attributes[name] = String(value);
330+
331+
if (document._mutationObserver && name === "href") {
332+
document._mutationObserver._callback([{
333+
type: "attributes",
334+
target: this,
335+
attributeName: "href"
336+
}]);
337+
}
338+
};
339+
340+
AnchorElement.prototype.hasAttribute = function(name) {
341+
return Object.prototype.hasOwnProperty.call(this._attributes, name);
342+
};
343+
344+
AnchorElement.prototype.matches = function(selector) {
345+
return selector === "a[href]" && this.hasAttribute("href");
346+
};
347+
348+
Object.defineProperty(AnchorElement.prototype, "href", {
349+
get: function() {
350+
return new URL(this.getAttribute("href") || "", document.baseURI).href;
351+
},
352+
set: function(value) {
353+
this.setAttribute("href", value);
354+
}
355+
});
356+
357+
function MutationObserver(callback) {
358+
this._callback = callback;
359+
}
360+
361+
MutationObserver.prototype.observe = function() {
362+
document._mutationObserver = this;
363+
};
364+
365+
MutationObserver.prototype.disconnect = function() {
366+
if (document._mutationObserver === this) {
367+
document._mutationObserver = null;
368+
}
369+
};
370+
232371
const dotNetObjectReference = {
233372
invokeMethodAsync: function() {
234373
return null;
@@ -238,14 +377,28 @@ function Element() {
238377
const document = {
239378
baseURI: "http://localhost/",
240379
_events: {},
380+
_anchors: [],
381+
_mutationObserver: null,
241382
addEventListener: function(name, handler) {
242383
this._events[name] = handler;
243384
},
244385
removeEventListener: function(name) {
245386
delete this._events[name];
387+
},
388+
querySelectorAll: function(selector) {
389+
if (selector !== "a[href]") {
390+
return [];
391+
}
392+
393+
return this._anchors.filter(function(anchor) {
394+
return anchor.matches(selector);
395+
});
246396
}
247397
};
248398
399+
document.body = document;
400+
document.documentElement = document;
401+
249402
const window = {
250403
_events: {},
251404
_lastReplacedHref: "",
@@ -295,6 +448,25 @@ function __setDocumentBaseUri(value) {
295448
document.baseURI = value;
296449
}
297450
451+
function __appendAnchor(href, target, download) {
452+
const anchor = new AnchorElement(href, target, Boolean(download));
453+
document._anchors.push(anchor);
454+
455+
if (document._mutationObserver) {
456+
document._mutationObserver._callback([{
457+
type: "childList",
458+
target: document.body,
459+
addedNodes: [anchor]
460+
}]);
461+
}
462+
463+
return document._anchors.length - 1;
464+
}
465+
466+
function __getAnchorHref(index) {
467+
return document._anchors[index].href;
468+
}
469+
298470
function __setLocationAndHistory(href, historyIndex, userState) {
299471
window._lastReplacedHref = "";
300472
window.location.href = href;

0 commit comments

Comments
 (0)