Skip to content

Commit fc1ebaa

Browse files
Make link click interception work with elements inside open shadow roots. Fixes #27070 (#27587)
Description HTML <a> elements within shadow roots are invisible to the link click interception code. So when an end user clicks on a link that's inside the shadow root of a custom element, it triggers a full page load instead of a client-side navigation. Customer Impact Reported by a customer at #27070. There's no reasonable workaround (besides not using custom elements and shadow DOM). The impact is that if you're using a library of custom HTML elements, which typically use ShadowDOM to render, then if you use them to produce any links, then those links will seem broken to the end user (as in, they trigger a full-page reload, destroying any app state in WebAssembly memory or discarding the Blazor Server circuit). Regression? No, this has always been the case. However, the use of custom elements and ShadowDOM is a growing area, especially as we try to guide customers to use custom elements like the FAST components. The fact that this use case is growing in importance makes it relevant to consider patching rather than waiting a full year for 6.0. We don't have to ship this in 5.0.1, but we should be prepared to ship it in a patch reasonably soon. Risk This changes the logic for processing clicks on all links, not just the ones in shadow roots. Although I'm not aware of any cases where the new logic might fail, and all our E2E tests pass, maybe I missed some case. It's unfortunate we don't have any opportunity to put this in front of customers any any preview form.
1 parent 6108c0d commit fc1ebaa

File tree

8 files changed

+58
-6
lines changed

8 files changed

+58
-6
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webassembly.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Services/NavigationManager.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function attachToEventDelegator(eventDelegator: EventDelegator) {
5252

5353
// Intercept clicks on all <a> elements where the href is within the <base href> URI space
5454
// We must explicitly check if it has an 'href' attribute, because if it doesn't, the result might be null or an empty string depending on the browser
55-
const anchorTarget = findClosestAncestor(event.target as Element | null, 'A') as HTMLAnchorElement | null;
55+
const anchorTarget = findAnchorTarget(event);
5656
const hrefAttributeName = 'href';
5757
if (anchorTarget && anchorTarget.hasAttribute(hrefAttributeName)) {
5858
const targetAttributeValue = anchorTarget.getAttribute('target');
@@ -122,12 +122,36 @@ export function toAbsoluteUri(relativeUri: string) {
122122
return testAnchor.href;
123123
}
124124

125-
function findClosestAncestor(element: Element | null, tagName: string) {
125+
function findAnchorTarget(event: MouseEvent): HTMLAnchorElement | null {
126+
// _blazorDisableComposedPath is a temporary escape hatch in case any problems are discovered
127+
// in this logic. It can be removed in a later release, and should not be considered supported API.
128+
const path = !window['_blazorDisableComposedPath'] && event.composedPath && event.composedPath();
129+
if (path) {
130+
// This logic works with events that target elements within a shadow root,
131+
// as long as the shadow mode is 'open'. For closed shadows, we can't possibly
132+
// know what internal element was clicked.
133+
for (let i = 0; i < path.length; i++) {
134+
const candidate = path[i];
135+
if (candidate instanceof Element && candidate.tagName === 'A') {
136+
return candidate as HTMLAnchorElement;
137+
}
138+
}
139+
return null;
140+
} else {
141+
// Since we're adding use of composedPath in a patch, retain compatibility with any
142+
// legacy browsers that don't support it by falling back on the older logic, even
143+
// though it won't work properly with ShadowDOM. This can be removed in the next
144+
// major release.
145+
return findClosestAnchorAncestorLegacy(event.target as Element | null, 'A');
146+
}
147+
}
148+
149+
function findClosestAnchorAncestorLegacy(element: Element | null, tagName: string) {
126150
return !element
127151
? null
128152
: element.tagName === tagName
129153
? element
130-
: findClosestAncestor(element.parentElement, tagName);
154+
: findClosestAnchorAncestorLegacy(element.parentElement, tagName);
131155
}
132156

133157
function isWithinBaseUriSpace(href: string) {

src/Components/test/E2ETest/Tests/RoutingTest.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,22 @@ public void CanFollowLinkToNotAComponent()
333333
Browser.Equal("Not a component!", () => Browser.Exists(By.Id("test-info")).Text);
334334
}
335335

336+
[Fact]
337+
public void CanFollowLinkDefinedInOpenShadowRoot()
338+
{
339+
SetUrlViaPushState("/");
340+
341+
var app = Browser.MountTestComponent<TestRouter>();
342+
343+
// It's difficult to access elements within a shadow root using Selenium's regular APIs
344+
// Bypass this limitation by clicking the element via JavaScript
345+
var shadowHost = app.FindElement(By.TagName("custom-link-with-shadow-root"));
346+
((IJavaScriptExecutor)Browser).ExecuteScript("arguments[0].shadowRoot.querySelector('a').click()", shadowHost);
347+
348+
Browser.Equal("This is another page.", () => app.FindElement(By.Id("test-info")).Text);
349+
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
350+
}
351+
336352
[Fact]
337353
public void CanGoBackFromNotAComponent()
338354
{

src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<li><NavLink href="/subdir/WithLazyLoadedRoutes" id="with-lazy-routes">With lazy loaded routes</NavLink></li>
2525
<li><NavLink href="PreventDefaultCases">preventDefault cases</NavLink></li>
2626
<li><NavLink>Null href never matches</NavLink></li>
27+
<li><custom-link-with-shadow-root target-url="Other"></custom-link-with-shadow-root></li>
2728
</ul>
2829

2930
<button id="do-navigation" @onclick=@(x => NavigationManager.NavigateTo("Other"))>

src/Components/test/testassets/BasicTestApp/wwwroot/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<script src="js/jsinteroptests.js"></script>
3131
<script src="js/renderattributestest.js"></script>
3232
<script src="js/webComponentPerformingJsInterop.js"></script>
33+
<script src="js/customLinkElement.js"></script>
3334

3435
<script>
3536
// Used by ElementRefComponent
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// This web component is used from the CanFollowLinkDefinedInOpenShadowRoot test case
2+
3+
window.customElements.define('custom-link-with-shadow-root', class extends HTMLElement {
4+
connectedCallback() {
5+
const shadowRoot = this.attachShadow({ mode: 'open' });
6+
const href = this.getAttribute('target-url');
7+
shadowRoot.innerHTML = `<a href='${href}'>Anchor tag within shadow root</a>`;
8+
}
9+
});

src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<script src="js/jsinteroptests.js"></script>
2121
<script src="js/renderattributestest.js"></script>
2222
<script src="js/webComponentPerformingJsInterop.js"></script>
23+
<script src="js/customLinkElement.js"></script>
2324

2425
<div id="blazor-error-ui">
2526
An unhandled error has occurred.

0 commit comments

Comments
 (0)