From b6003803c74ec4347082d3aa1f36a96d6a78ae93 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 4 Dec 2025 15:43:09 +0100 Subject: [PATCH 1/2] [Blazor] Support NavigateTo when enhanced nav is disabled (#52267) (#64542) * Support NavigateTo when enhanced nav is disabled (#52267) Makes the NavigateTo API work even if enhanced nav is disabled via config. By default, Blazor apps have either enhanced nav or an interactive router . In these default cases, the `NavigateTo` API works correctly. However there's also an obscure way to disable both of these via config. It's niche, but it's supported, so the rest of the system should work with that. Unfortunately `NavigateTo` assumes that either enhanced nav or an interactive router will be enabled and doesn't account for the case when neither is. Fixes #51636 Without this fix, anyone who uses the `ssr: { disableDomPreservation: true }` config option will be unable to use the `NavigateTo` API, as it will do nothing. This behavior isn't desirable. - [ ] Yes - [x] No No because existing code can't use `ssr: { disableDomPreservation: true }` as the option didn't exist prior to .NET 8. Someone else might argue that it's a regression in the sense that, if you're migrating existing code to use newer .NET 8 patterns (and are using `disableDomPreservation` for some reason, even though you wouldn't normally), your existing uses of `NavigateTo` could stop working. That's not how we normally define "regression" but I'm trying to give the fullest explanation. - [ ] High - [ ] Medium - [x] Low The fix explicitly retains the old code path if you're coming from .NET 7 or earlier (i.e., if you are using `blazor.webassembly/server/webview.js`. The fixed code path is only applied in `blazor.web.js`, so it should not affect existing apps that are simply moving to the `net8.0` TFM without other code changes. - [x] Manual (required) - [x] Automated - [ ] Yes - [ ] No - [x] N/A * Remove redundant IsElementStale method and use WebDriverExtensions.IsStale (#64549) * Initial plan * Remove redundant IsElementStale method and use WebDriverExtensions.IsStale Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> * Remove IsElementStale method and use existing WebDriverExtensions.IsStale (#64550) * Initial plan * Remove IsElementStale and use WebDriverExtensions.IsStale Co-authored-by: ilonatommy <32700855+ilonatommy@users.noreply.github.com> * Remove unused private IsElementStale method from EnhancedNavigationTest.cs Co-authored-by: ilonatommy <32700855+ilonatommy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ilonatommy <32700855+ilonatommy@users.noreply.github.com> --------- Co-authored-by: Steve Sanderson Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: ilonatommy <32700855+ilonatommy@users.noreply.github.com> --- src/Components/Web.JS/src/Boot.Web.ts | 1 + src/Components/Web.JS/src/GlobalExports.ts | 1 + .../Web.JS/src/Services/NavigationManager.ts | 38 ++++++++++++++----- .../ServerRenderingTests/InteractivityTest.cs | 25 ++++++++++++ .../Interactivity/InteractiveNavigateTo.razor | 18 +++++++++ .../Shared/EnhancedNavLayout.razor | 4 +- 6 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/InteractiveNavigateTo.razor diff --git a/src/Components/Web.JS/src/Boot.Web.ts b/src/Components/Web.JS/src/Boot.Web.ts index df605ceebc52..0c7599d4cef0 100644 --- a/src/Components/Web.JS/src/Boot.Web.ts +++ b/src/Components/Web.JS/src/Boot.Web.ts @@ -39,6 +39,7 @@ function boot(options?: Partial) : Promise { started = true; options = options || {}; options.logLevel ??= LogLevel.Error; + Blazor._internal.isBlazorWeb = true; // Defined here to avoid inadvertently imported enhanced navigation // related APIs in WebAssembly or Blazor Server contexts. diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 4e3bf21e6fe4..5a6e24ae0235 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -80,6 +80,7 @@ export interface IBlazor { receiveWebAssemblyDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void; receiveWebViewDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void; attachWebRendererInterop?: typeof attachWebRendererInterop; + isBlazorWeb?: boolean; // JSExport APIs dotNetExports?: { diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts index 8e2de809505a..a76e854980e9 100644 --- a/src/Components/Web.JS/src/Services/NavigationManager.ts +++ b/src/Components/Web.JS/src/Services/NavigationManager.ts @@ -7,6 +7,7 @@ import { EventDelegator } from '../Rendering/Events/EventDelegator'; import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isForSamePath, isSamePageWithHash, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, performScrollToElementOnTheSamePage, scrollToElement, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils'; import { WebRendererId } from '../Rendering/WebRendererId'; import { isRendererAttached } from '../Rendering/WebRendererInteropMethods'; +import { IBlazor } from '../GlobalExports'; let hasRegisteredNavigationEventListeners = false; let currentHistoryIndex = 0; @@ -116,18 +117,21 @@ function navigateToFromDotNet(uri: string, options: NavigationOptions): void { function navigateToCore(uri: string, options: NavigationOptions, skipLocationChangingCallback = false): void { const absoluteUri = toAbsoluteUri(uri); + const pageLoadMechanism = currentPageLoadMechanism(); - if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) { - if (shouldUseClientSideRouting()) { - performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState, skipLocationChangingCallback); - } else { - performProgrammaticEnhancedNavigation(absoluteUri, options.replaceHistoryEntry); - } - } else { + if (options.forceLoad || !isWithinBaseUriSpace(absoluteUri) || pageLoadMechanism === 'serverside-fullpageload') { // For external navigation, we work in terms of the originally-supplied uri string, // not the computed absoluteUri. This is in case there are some special URI formats // we're unable to translate into absolute URIs. performExternalNavigation(uri, options.replaceHistoryEntry); + } else if (pageLoadMechanism === 'clientside-router') { + performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState, skipLocationChangingCallback); + } else if (pageLoadMechanism === 'serverside-enhanced') { + performProgrammaticEnhancedNavigation(absoluteUri, options.replaceHistoryEntry); + } else { + // Force a compile-time error if some other case needs to be handled in the future + const unreachable: never = pageLoadMechanism; + throw new Error(`Unsupported page load mechanism: ${unreachable}`); } } @@ -266,7 +270,7 @@ async function notifyLocationChanged(interceptedLink: boolean, internalDestinati } async function onPopState(state: PopStateEvent) { - if (popStateCallback && shouldUseClientSideRouting()) { + if (popStateCallback && currentPageLoadMechanism() !== 'serverside-enhanced') { await popStateCallback(state); } @@ -282,10 +286,24 @@ function getInteractiveRouterNavigationCallbacks(): NavigationCallbacks | undefi return navigationCallbacks.get(interactiveRouterRendererId); } -function shouldUseClientSideRouting() { - return hasInteractiveRouter() || !hasProgrammaticEnhancedNavigationHandler(); +function currentPageLoadMechanism(): PageLoadMechanism { + if (hasInteractiveRouter()) { + return 'clientside-router'; + } else if (hasProgrammaticEnhancedNavigationHandler()) { + return 'serverside-enhanced'; + } else { + // For back-compat, in blazor.server.js or blazor.webassembly.js, we always behave as if there's an interactive + // router even if there isn't one attached. This preserves a niche case where people may call Blazor.navigateTo + // without a router and expect to receive a notification on the .NET side but no page load occurs. + // In blazor.web.js, we explicitly recognize the case where you have neither an interactive nor enhanced SSR router + // attached, and then handle Blazor.navigateTo by doing a full page load because that's more useful (issue #51636). + const isBlazorWeb = (window['Blazor'] as IBlazor)._internal.isBlazorWeb; + return isBlazorWeb ? 'serverside-fullpageload' : 'clientside-router'; + } } +type PageLoadMechanism = 'clientside-router' | 'serverside-enhanced' | 'serverside-fullpageload'; + // Keep in sync with Components/src/NavigationOptions.cs export interface NavigationOptions { forceLoad: boolean; diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index c2c4e6c66340..8ad4cbb048e4 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests; @@ -1287,6 +1288,30 @@ void SetUpPageWithOneInteractiveServerComponent() } } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void CanPerformNavigateToFromInteractiveEventHandler(bool suppressEnhancedNavigation, bool forceLoad) + { + EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, suppressEnhancedNavigation); + + // Get to the test page + Navigate($"{ServerPathBase}/interactivity/navigateto"); + Browser.Equal("Interactive NavigateTo", () => Browser.FindElement(By.TagName("h1")).Text); + var originalNavElem = Browser.FindElement(By.TagName("nav")); + + // Perform the navigation + Browser.Click(By.Id(forceLoad ? "perform-navigateto-force" : "perform-navigateto")); + Browser.True(() => Browser.Url.EndsWith("/nav", StringComparison.Ordinal)); + Browser.Equal("Hello", () => Browser.FindElement(By.Id("nav-home")).Text); + + // Verify the elements were preserved if and only if they should be + var shouldPreserveElements = !suppressEnhancedNavigation && !forceLoad; + Assert.Equal(shouldPreserveElements, !originalNavElem.IsStale()); + } + private void BlockWebAssemblyResourceLoad() { // Force a WebAssembly resource cache miss so that we can fall back to using server interactivity diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/InteractiveNavigateTo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/InteractiveNavigateTo.razor new file mode 100644 index 000000000000..82ff639c8595 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/InteractiveNavigateTo.razor @@ -0,0 +1,18 @@ +@page "/interactivity/navigateto" +@layout Components.TestServer.RazorComponents.Shared.EnhancedNavLayout +@inject NavigationManager Nav +@rendermode RenderMode.InteractiveServer + +

Interactive NavigateTo

+ +

Shows that NavigateTo from an interactive event handler works as expected, with or without enhanced navigation.

+ + + + +@code { + void PerformNavigateTo(bool forceLoad) + { + Nav.NavigateTo("nav", forceLoad); + } +} 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 7e9166fabb1e..25544afef64e 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor @@ -45,4 +45,6 @@

-@Body +
+ @Body +
From fd628d254952567e0560579302a753cb1268e0c7 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 10 Dec 2025 14:09:32 +0100 Subject: [PATCH 2/2] Add missing IsStale extension method for backport compatibility --- .../WebDriverExtensions/WebDriverExtensions.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs b/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs index fd24da459497..96ce725760b5 100644 --- a/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs +++ b/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs @@ -64,4 +64,17 @@ public static long GetElementPositionWithRetry(this IWebDriver browser, string e throw new Exception($"Failed to get position for element '{elementId}' after {retryCount} retries. Debug log: {log}"); } + + internal static bool IsStale(this IWebElement element) + { + try + { + _ = element.Enabled; + return false; + } + catch (StaleElementReferenceException) + { + return true; + } + } }