diff --git a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts index 08cfd8f86608..23ba7676a643 100644 --- a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts @@ -6,6 +6,7 @@ import { EventDelegator } from './Events/EventDelegator'; import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, isMathMLElement, permuteLogicalChildren, getClosestDomElement, emptyLogicalElement, getLogicalChildrenArray, depthFirstNodeTreeTraversal } from './LogicalElements'; import { applyCaptureIdToElement } from './ElementReferenceCapture'; import { attachToEventDelegator as attachNavigationManagerToEventDelegator } from '../Services/NavigationManager'; +import { attachEnhancedNavigationClickHandlerToEventDelegator } from '../Services/NavigationEnhancement'; import { applyAnyDeferredValue, tryApplySpecialProperty } from './DomSpecialPropertyUtil'; const sharedTemplateElemForParsing = document.createElement('template'); const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g'); @@ -31,6 +32,11 @@ export class BrowserRenderer { // we wire up the navigation manager to the event delegator so it has the option to participate // in the synthetic event bubbling process later attachNavigationManagerToEventDelegator(this.eventDelegator); + + // Similarly, wire up enhanced navigation so that @onclick:preventDefault can be respected. + // This ensures enhanced navigation click handling runs after EventDelegator processes Blazor event handlers. + // See https://github.com/dotnet/aspnetcore/issues/52514 + attachEnhancedNavigationClickHandlerToEventDelegator(this.eventDelegator.notifyAfterClick.bind(this.eventDelegator)); } public getRootComponentCount(): number { diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index 9eb91a80e01d..3c6f015aa7c5 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -38,6 +38,10 @@ let currentEnhancedNavigationAbortController: AbortController | null; let navigationEnhancementCallbacks: NavigationEnhancementCallbacks; let performingEnhancedPageLoad: boolean; +// When an EventDelegator is available, we defer click handling to it so that +// @onclick:preventDefault can be respected. See https://github.com/dotnet/aspnetcore/issues/52514 +let hasEventDelegatorClickHandler: boolean = false; + // This gets initialized to the current URL when we load. // After that, it gets updated every time we successfully complete a navigation. let currentContentUrl = location.href; @@ -71,23 +75,18 @@ export function detachProgressivelyEnhancedNavigationListener() { window.removeEventListener('popstate', onPopState); } -function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, replace: boolean) : void { - const originalLocation = location.href; - - if (replace) { - history.replaceState(null, /* ignored title */ '', absoluteInternalHref); - } else { - history.pushState(null, /* ignored title */ '', absoluteInternalHref); - } - - if (!isForSamePath(absoluteInternalHref, originalLocation)) { - scheduleScrollReset(ScrollResetSchedule.AfterDocumentUpdate); - } - - performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ false); +// Called from BrowserRenderer to wire up the enhanced navigation click handler through +// the EventDelegator, ensuring it runs after @onclick:preventDefault has been processed. +// See https://github.com/dotnet/aspnetcore/issues/52514 +export function attachEnhancedNavigationClickHandlerToEventDelegator(notifyAfterClick: (callback: (event: MouseEvent) => void) => void) { + hasEventDelegatorClickHandler = true; + notifyAfterClick(handleEnhancedNavigationClick); } -function onDocumentClick(event: MouseEvent) { +// Core logic for handling a click for enhanced navigation purposes. +// This is called either from the native click handler (pure SSR) or from EventDelegator's +// afterClick callback (interactive scenarios). +function handleEnhancedNavigationClick(event: MouseEvent) { if (hasInteractiveRouter()) { return; } @@ -114,6 +113,35 @@ function onDocumentClick(event: MouseEvent) { }); } +function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, replace: boolean) : void { + const originalLocation = location.href; + + if (replace) { + history.replaceState(null, /* ignored title */ '', absoluteInternalHref); + } else { + history.pushState(null, /* ignored title */ '', absoluteInternalHref); + } + + if (!isForSamePath(absoluteInternalHref, originalLocation)) { + scheduleScrollReset(ScrollResetSchedule.AfterDocumentUpdate); + } + + performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ false); +} + +function onDocumentClick(event: MouseEvent) { + // If we have an EventDelegator handling clicks, skip this native handler. + // The EventDelegator will call handleEnhancedNavigationClick after processing + // @onclick:preventDefault and other Blazor event handlers. + // See https://github.com/dotnet/aspnetcore/issues/52514 + if (hasEventDelegatorClickHandler) { + return; + } + + // No EventDelegator available (pure SSR), handle the click directly. + handleEnhancedNavigationClick(event); +} + function onPopState(state: PopStateEvent) { if (hasInteractiveRouter()) { return; diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index 5b28c9d299ac..d0b799e3184f 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -697,6 +697,41 @@ public void CanReceiveNullParameterValueOnEnhancedNavigation(string renderMode) Assert.DoesNotContain(logs, log => log.Message.Contains("Error")); } + [Fact] + public void PreventDefaultOnClickIsRespectedWithEnhancedNavigation() + { + // See: https://github.com/dotnet/aspnetcore/issues/52514 + // Verifies that @onclick:preventDefault on a link prevents enhanced navigation from intercepting the click + Navigate($"{ServerPathBase}/nav"); + Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); + + Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("PreventDefault link test")).Click(); + Browser.Equal("PreventDefault Link Test", () => Browser.Exists(By.TagName("h1")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("current-count")).Text); + + // Store the current URL to verify it doesn't change + var originalUrl = Browser.Url; + Assert.EndsWith("/nav/prevent-default-link", originalUrl); + + // Click the link with @onclick:preventDefault - should increment count but NOT navigate + Browser.Exists(By.Id("prevent-default-link")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("current-count")).Text); + Assert.Equal(originalUrl, Browser.Url); // URL should not have changed + + // Click again to verify it continues working + Browser.Exists(By.Id("prevent-default-link")).Click(); + Browser.Equal("2", () => Browser.Exists(By.Id("current-count")).Text); + Assert.Equal(originalUrl, Browser.Url); + + // Also verify the button works (reference case) + Browser.Exists(By.Id("increment-button")).Click(); + Browser.Equal("3", () => Browser.Exists(By.Id("current-count")).Text); + + // No errors should be logged + var logs = Browser.GetBrowserLogs(LogLevel.Warning); + Assert.DoesNotContain(logs, log => log.Message.Contains("Error")); + } + [Fact] public void CanUpdateHrefOnLinkTagWithIntegrity() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageWithPreventDefaultLink.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageWithPreventDefaultLink.razor new file mode 100644 index 000000000000..07d5ace1f4c1 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageWithPreventDefaultLink.razor @@ -0,0 +1,45 @@ +@page "/nav/prevent-default-link" +@rendermode RenderMode.InteractiveServer + +PreventDefault Link Test + +

PreventDefault Link Test

+ +

+ This page tests whether @@onclick:preventDefault works correctly with enhanced navigation. + See issue #52514. +

+ +

Current Count: @currentCount

+ +

Test Case 1: Link with preventDefault

+

Click the link below. The counter should increment and you should NOT navigate away.

+

+ + Click me (with preventDefault) + +

+ +

Test Case 2: Link with preventDefault AND data-enhance-nav="false" (Workaround)

+

Click the link below. This uses the known workaround.

+

+ + Click me (with workaround) + +

+ +

Test Case 3: Button (Reference)

+

+ +

+ +

Current URL should stay at /nav/prevent-default-link

+ +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor index 25544afef64e..6c20de51c949 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor @@ -25,6 +25,7 @@ LocationChanged/LocationChanging event (server-and-wasm) Null component parameter (server) Null component parameter (wasm) + PreventDefault link test