From 14d37afef05201099ff929f848a7cd4bd9e3b795 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 23 Nov 2023 00:20:54 +0000 Subject: [PATCH 1/2] 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 --- src/Components/Web.JS/src/Boot.Web.ts | 1 + src/Components/Web.JS/src/GlobalExports.ts | 1 + .../Web.JS/src/Services/NavigationManager.ts | 38 ++++++++++++++----- .../EnhancedNavigationTest.cs | 3 ++ .../EnhancedNavigationTestUtil.cs | 13 +++++++ .../ServerRenderingTests/InteractivityTest.cs | 24 ++++++++++++ .../Interactivity/InteractiveNavigateTo.razor | 18 +++++++++ .../Shared/EnhancedNavLayout.razor | 4 +- 8 files changed, 91 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 9abcfee32287..82ab6352e8f5 100644 --- a/src/Components/Web.JS/src/Boot.Web.ts +++ b/src/Components/Web.JS/src/Boot.Web.ts @@ -40,6 +40,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 02cb2bdd6095..6bd2fbe69c75 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -84,6 +84,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 20b0340973ed..29e1d892d354 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/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index 5b28c9d299ac..c7f52c3c155e 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -989,4 +989,7 @@ private static Func ElementWithTextAppears(By selector, string // Ensure we actually observed the new content, not just the presence of the element. return string.Equals(elements[0].Text, expectedText, StringComparison.Ordinal); }; + + private static bool IsElementStale(IWebElement element) + => EnhancedNavigationTestUtil.IsElementStale(element); } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs index 1fa7b8fce9e3..12fac8e74548 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs @@ -97,4 +97,17 @@ public static long GetScrollY(this IWebDriver browser) public static long SetScrollY(this IWebDriver browser, long value) => Convert.ToInt64(((IJavaScriptExecutor)browser).ExecuteScript($"window.scrollTo(0, {value})"), CultureInfo.CurrentCulture); + + public static bool IsElementStale(IWebElement element) + { + try + { + _ = element.Enabled; + return false; + } + catch (StaleElementReferenceException) + { + return true; + } + } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index c2c4e6c66340..4ea368a72437 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1287,6 +1287,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, !EnhancedNavigationTestUtil.IsElementStale(originalNavElem)); + } + 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 485495f7d2ada647aa547fa1db197c7149ead39a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:35:32 +0100 Subject: [PATCH 2/2] 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> --- .../ServerRenderingTests/EnhancedNavigationTest.cs | 3 --- .../EnhancedNavigationTestUtil.cs | 13 ------------- .../ServerRenderingTests/InteractivityTest.cs | 2 +- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index c7f52c3c155e..5b28c9d299ac 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -989,7 +989,4 @@ private static Func ElementWithTextAppears(By selector, string // Ensure we actually observed the new content, not just the presence of the element. return string.Equals(elements[0].Text, expectedText, StringComparison.Ordinal); }; - - private static bool IsElementStale(IWebElement element) - => EnhancedNavigationTestUtil.IsElementStale(element); } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs index 12fac8e74548..1fa7b8fce9e3 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTestUtil.cs @@ -97,17 +97,4 @@ public static long GetScrollY(this IWebDriver browser) public static long SetScrollY(this IWebDriver browser, long value) => Convert.ToInt64(((IJavaScriptExecutor)browser).ExecuteScript($"window.scrollTo(0, {value})"), CultureInfo.CurrentCulture); - - public static bool IsElementStale(IWebElement element) - { - try - { - _ = element.Enabled; - return false; - } - catch (StaleElementReferenceException) - { - return true; - } - } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 4ea368a72437..4fe0a5d42647 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1308,7 +1308,7 @@ public void CanPerformNavigateToFromInteractiveEventHandler(bool suppressEnhance // Verify the elements were preserved if and only if they should be var shouldPreserveElements = !suppressEnhancedNavigation && !forceLoad; - Assert.Equal(shouldPreserveElements, !EnhancedNavigationTestUtil.IsElementStale(originalNavElem)); + Assert.Equal(shouldPreserveElements, !originalNavElem.IsStale()); } private void BlockWebAssemblyResourceLoad()