Browse Source

Navigate with "replace" param (#33751)

Steve Sanderson 4 years ago
parent
commit
ed383aaded

+ 53 - 3
src/Components/Components/src/NavigationManager.cs

@@ -90,10 +90,46 @@ namespace Microsoft.AspNetCore.Components
         /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
         /// (as returned by <see cref="BaseUri"/>).</param>
         /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
-        public void NavigateTo(string uri, bool forceLoad = false)
+        public void NavigateTo(string uri, bool forceLoad) // This overload is for binary back-compat with < 6.0
+            => NavigateTo(uri, forceLoad, replace: false);
+
+        /// <summary>
+        /// Navigates to the specified URI.
+        /// </summary>
+        /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
+        /// (as returned by <see cref="BaseUri"/>).</param>
+        /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
+        /// <param name="replace">If true, replaces the currently entry in the history stack. If false, appends the new entry to the history stack.</param>
+        public void NavigateTo(string uri, bool forceLoad = false, bool replace = false)
+        {
+            AssertInitialized();
+
+            if (replace)
+            {
+                NavigateToCore(uri, new NavigationOptions
+                {
+                    ForceLoad = forceLoad,
+                    ReplaceHistoryEntry = replace,
+                });
+            }
+            else
+            {
+                // For back-compatibility, we must call the (string, bool) overload of NavigateToCore from here,
+                // because that's the only overload guaranteed to be implemented in subclasses.
+                NavigateToCore(uri, forceLoad);
+            }
+        }
+
+        /// <summary>
+        /// Navigates to the specified URI.
+        /// </summary>
+        /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
+        /// (as returned by <see cref="BaseUri"/>).</param>
+        /// <param name="options">Provides additional <see cref="NavigationOptions"/>.</param>
+        public void NavigateTo(string uri, NavigationOptions options)
         {
             AssertInitialized();
-            NavigateToCore(uri, forceLoad);
+            NavigateToCore(uri, options);
         }
 
         /// <summary>
@@ -102,7 +138,21 @@ namespace Microsoft.AspNetCore.Components
         /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
         /// (as returned by <see cref="BaseUri"/>).</param>
         /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
-        protected abstract void NavigateToCore(string uri, bool forceLoad);
+        // The reason this overload exists and is virtual is for back-compat with < 6.0. Existing NavigationManager subclasses may
+        // already override this, so the framework needs to keep using it for the cases when only pre-6.0 options are used.
+        // However, for anyone implementing a new NavigationManager post-6.0, we don't want them to have to override this
+        // overload any more, so there's now a default implementation that calls the updated overload.
+        protected virtual void NavigateToCore(string uri, bool forceLoad)
+            => NavigateToCore(uri, new NavigationOptions { ForceLoad = forceLoad });
+
+        /// <summary>
+        /// Navigates to the specified URI.
+        /// </summary>
+        /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
+        /// (as returned by <see cref="BaseUri"/>).</param>
+        /// <param name="options">Provides additional <see cref="NavigationOptions"/>.</param>
+        protected virtual void NavigateToCore(string uri, NavigationOptions options) =>
+            throw new NotImplementedException($"The type {GetType().FullName} does not support supplying {nameof(NavigationOptions)}. To add support, that type should override {nameof(NavigateToCore)}(string uri, {nameof(NavigationOptions)} options).");
 
         /// <summary>
         /// Called to initialize BaseURI and current URI before these values are used for the first time.

+ 22 - 0
src/Components/Components/src/NavigationOptions.cs

@@ -0,0 +1,22 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components
+{
+    /// <summary>
+    /// Additional options for navigating to another URI.
+    /// </summary>
+    public readonly struct NavigationOptions
+    {
+        /// <summary>
+        /// If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.
+        /// </summary>
+        public bool ForceLoad { get; init; }
+
+        /// <summary>
+        /// If true, replaces the currently entry in the history stack.
+        /// If false, appends the new entry to the history stack.
+        /// </summary>
+        public bool ReplaceHistoryEntry { get; init; }
+    }
+}

+ 13 - 0
src/Components/Components/src/PublicAPI.Unshipped.txt

@@ -2,6 +2,8 @@
 *REMOVED*static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object!>! parameters) -> Microsoft.AspNetCore.Components.ParameterView
 *REMOVED*virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo! fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!
 *REMOVED*readonly Microsoft.AspNetCore.Components.RenderTree.RenderTreeEdit.RemovedAttributeName -> string!
+*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad = false) -> void
+*REMOVED*abstract Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, bool forceLoad) -> void
 Microsoft.AspNetCore.Components.ComponentApplicationState
 Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersisting -> Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersistingCallback!
 Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersistingCallback
@@ -29,6 +31,15 @@ Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.State.get
 Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore
 Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore.GetPersistedStateAsync() -> System.Threading.Tasks.Task<System.Collections.Generic.IDictionary<string!, byte[]!>!>!
 Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore.PersistStateAsync(System.Collections.Generic.IReadOnlyDictionary<string!, byte[]!>! state) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, Microsoft.AspNetCore.Components.NavigationOptions options) -> void
+Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad = false, bool replace = false) -> void
+Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad) -> void
+Microsoft.AspNetCore.Components.NavigationOptions
+Microsoft.AspNetCore.Components.NavigationOptions.ForceLoad.get -> bool
+Microsoft.AspNetCore.Components.NavigationOptions.ForceLoad.init -> void
+Microsoft.AspNetCore.Components.NavigationOptions.NavigationOptions() -> void
+Microsoft.AspNetCore.Components.NavigationOptions.ReplaceHistoryEntry.get -> bool
+Microsoft.AspNetCore.Components.NavigationOptions.ReplaceHistoryEntry.init -> void
 Microsoft.AspNetCore.Components.RenderHandle.IsHotReloading.get -> bool
 Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.Dispose() -> void
 Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool
@@ -49,6 +60,8 @@ Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong event
 abstract Microsoft.AspNetCore.Components.ErrorBoundaryBase.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
 override Microsoft.AspNetCore.Components.LayoutComponentBase.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
 static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object?>! parameters) -> Microsoft.AspNetCore.Components.ParameterView
+virtual Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, Microsoft.AspNetCore.Components.NavigationOptions options) -> void
+virtual Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, bool forceLoad) -> void
 virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!
 readonly Microsoft.AspNetCore.Components.RenderTree.RenderTreeEdit.RemovedAttributeName -> string?
 *REMOVED*Microsoft.AspNetCore.Components.CascadingValue<TValue>.Value.get -> TValue

+ 0 - 2
src/Components/Components/test/Routing/RouterTest.cs

@@ -209,8 +209,6 @@ namespace Microsoft.AspNetCore.Components.Routing
             public TestNavigationManager() =>
                 Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan");
 
-            protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotImplementedException();
-
             public void NotifyLocationChanged(string uri, bool intercepted)
             {
                 Uri = uri;

+ 9 - 7
src/Components/Server/src/Circuits/RemoteNavigationManager.cs

@@ -2,6 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Diagnostics.CodeAnalysis;
 using Microsoft.AspNetCore.Components.Routing;
 using Microsoft.Extensions.Logging;
 using Microsoft.JSInterop;
@@ -65,9 +66,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
         }
 
         /// <inheritdoc />
-        protected override void NavigateToCore(string uri, bool forceLoad)
+        [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(NavigationOptions))]
+        protected override void NavigateToCore(string uri, NavigationOptions options)
         {
-            Log.RequestingNavigation(_logger, uri, forceLoad);
+            Log.RequestingNavigation(_logger, uri, options);
 
             if (_jsRuntime == null)
             {
@@ -75,20 +77,20 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
                 throw new NavigationException(absoluteUriString);
             }
 
-            _jsRuntime.InvokeAsync<object>(Interop.NavigateTo, uri, forceLoad).Preserve();
+            _jsRuntime.InvokeVoidAsync(Interop.NavigateTo, uri, options).Preserve();
         }
 
         private static class Log
         {
-            private static readonly Action<ILogger, string, bool, Exception> _requestingNavigation =
-                LoggerMessage.Define<string, bool>(LogLevel.Debug, new EventId(1, "RequestingNavigation"), "Requesting navigation to URI {Uri} with forceLoad={ForceLoad}");
+            private static readonly Action<ILogger, string, bool, bool, Exception> _requestingNavigation =
+                LoggerMessage.Define<string, bool, bool>(LogLevel.Debug, new EventId(1, "RequestingNavigation"), "Requesting navigation to URI {Uri} with forceLoad={ForceLoad}, replace={Replace}");
 
             private static readonly Action<ILogger, string, bool, Exception> _receivedLocationChangedNotification =
                 LoggerMessage.Define<string, bool>(LogLevel.Debug, new EventId(2, "ReceivedLocationChangedNotification"), "Received notification that the URI has changed to {Uri} with isIntercepted={IsIntercepted}");
 
-            public static void RequestingNavigation(ILogger logger, string uri, bool forceLoad)
+            public static void RequestingNavigation(ILogger logger, string uri, NavigationOptions options)
             {
-                _requestingNavigation(logger, uri, forceLoad, null);
+                _requestingNavigation(logger, uri, options.ForceLoad, options.ReplaceHistoryEntry, null);
             }
 
             public static void ReceivedLocationChangedNotification(ILogger logger, string uri, bool isIntercepted)

File diff suppressed because it is too large
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


File diff suppressed because it is too large
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.webview.js


+ 2 - 2
src/Components/Web.JS/src/GlobalExports.ts

@@ -1,4 +1,4 @@
-import { navigateTo, internalFunctions as navigationManagerInternalFunctions } from './Services/NavigationManager';
+import { navigateTo, internalFunctions as navigationManagerInternalFunctions, NavigationOptions } from './Services/NavigationManager';
 import { domFunctions } from './DomWrapper';
 import { Virtualize } from './Virtualize';
 import { registerCustomEventType, EventTypeOptions } from './Rendering/Events/EventTypes';
@@ -10,7 +10,7 @@ import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
 import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean, System_Byte, System_Int } from './Platform/Platform';
 
 interface IBlazor {
-  navigateTo: (uri: string, forceLoad: boolean, replace: boolean) => void;
+  navigateTo: (uri: string, options: NavigationOptions) => void;
   registerCustomEventType: (eventName: string, options: EventTypeOptions) => void;
 
   disconnect?: () => void;

+ 39 - 15
src/Components/Web.JS/src/Services/NavigationManager.ts

@@ -60,46 +60,64 @@ export function attachToEventDelegator(eventDelegator: EventDelegator) {
 
       if (isWithinBaseUriSpace(absoluteHref)) {
         event.preventDefault();
-        performInternalNavigation(absoluteHref, true);
+        performInternalNavigation(absoluteHref, /* interceptedLink */ true, /* replace */ false);
       }
     }
   });
 }
 
-export function navigateTo(uri: string, forceLoad: boolean, replace: boolean = false) {
+// For back-compat, we need to accept multiple overloads
+export function navigateTo(uri: string, options: NavigationOptions): void;
+export function navigateTo(uri: string, forceLoad: boolean): void;
+export function navigateTo(uri: string, forceLoad: boolean, replace: boolean): void;
+export function navigateTo(uri: string, forceLoadOrOptions: NavigationOptions | boolean, replaceIfUsingOldOverload: boolean = false) {
   const absoluteUri = toAbsoluteUri(uri);
 
-  if (!forceLoad && isWithinBaseUriSpace(absoluteUri)) {
-    // It's an internal URL, so do client-side navigation
-    performInternalNavigation(absoluteUri, false, replace);
-  } else if (forceLoad && location.href === uri) {
-    // Force-loading the same URL you're already on requires special handling to avoid
-    // triggering browser-specific behavior issues.
+  // Normalize the parameters to the newer overload (i.e., using NavigationOptions)
+  const options: NavigationOptions = forceLoadOrOptions instanceof Object
+    ? forceLoadOrOptions
+    : { forceLoad: forceLoadOrOptions, replaceHistoryEntry: replaceIfUsingOldOverload };
+
+  if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) {
+    performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry);
+  } else {
+    // 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);
+  }
+}
+
+function performExternalNavigation(uri: string, replace: boolean) {
+  if (location.href === uri) {
+    // If you're already on this URL, you can't append another copy of it to the history stack,
+    // so we can ignore the 'replace' flag. However, reloading the same URL you're already on
+    // requires special handling to avoid triggering browser-specific behavior issues.
     // For details about what this fixes and why, see https://github.com/dotnet/aspnetcore/pull/10839
     const temporaryUri = uri + '?';
     history.replaceState(null, '', temporaryUri);
     location.replace(uri);
-  } else if (replace){
-    history.replaceState(null, '', absoluteUri)
+  } else if (replace) {
+    location.replace(uri);
   } else {
-    // It's either an external URL, or forceLoad is requested, so do a full page load
     location.href = uri;
   }
 }
 
-function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean = false) {
+function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean) {
   // Since this was *not* triggered by a back/forward gesture (that goes through a different
   // code path starting with a popstate event), we don't want to preserve the current scroll
   // position, so reset it.
-  // To avoid ugly flickering effects, we don't want to change the scroll position until the
+  // To avoid ugly flickering effects, we don't want to change the scroll position until
   // we render the new page. As a best approximation, wait until the next batch.
   resetScrollAfterNextBatch();
 
-  if(!replace){
+  if (!replace) {
     history.pushState(null, /* ignored title */ '', absoluteInternalHref);
-  }else{
+  } else {
     history.replaceState(null, /* ignored title */ '', absoluteInternalHref);
   }
+
   notifyLocationChanged(interceptedLink);
 }
 
@@ -166,3 +184,9 @@ function canProcessAnchor(anchorTarget: HTMLAnchorElement) {
   const opensInSameFrame = !targetAttributeValue || targetAttributeValue === '_self';
   return opensInSameFrame && anchorTarget.hasAttribute('href') && !anchorTarget.hasAttribute('download');
 }
+
+// Keep in sync with Components/src/NavigationOptions.cs
+export interface NavigationOptions {
+  forceLoad: boolean;
+  replaceHistoryEntry: boolean;
+}

+ 4 - 2
src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyNavigationManager.cs

@@ -2,6 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Diagnostics.CodeAnalysis;
 using Microsoft.JSInterop;
 using Interop = Microsoft.AspNetCore.Components.Web.BrowserNavigationManagerInterop;
 
@@ -29,14 +30,15 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services
         }
 
         /// <inheritdoc />
-        protected override void NavigateToCore(string uri, bool forceLoad)
+        [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(NavigationOptions))]
+        protected override void NavigateToCore(string uri, NavigationOptions options)
         {
             if (uri == null)
             {
                 throw new ArgumentNullException(nameof(uri));
             }
 
-            DefaultWebAssemblyJSRuntime.Instance.InvokeVoid(Interop.NavigateTo, uri, forceLoad);
+            DefaultWebAssemblyJSRuntime.Instance.InvokeVoid(Interop.NavigateTo, uri, options);
         }
     }
 }

+ 4 - 2
src/Components/WebView/WebView/src/IpcSender.cs

@@ -2,6 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Diagnostics.CodeAnalysis;
 using System.Threading.Tasks;
 using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.JSInterop;
@@ -33,9 +34,10 @@ namespace Microsoft.AspNetCore.Components.WebView
             DispatchMessageWithErrorHandling(message);
         }
 
-        public void Navigate(string uri, bool forceLoad)
+        [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(NavigationOptions))]
+        public void Navigate(string uri, NavigationOptions options)
         {
-            DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.Navigate, uri, forceLoad));
+            DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.Navigate, uri, options));
         }
 
         public void AttachToDocument(int componentId, string selector)

+ 2 - 2
src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs

@@ -19,9 +19,9 @@ namespace Microsoft.AspNetCore.Components.WebView.Services
             NotifyLocationChanged(intercepted);
         }
 
-        protected override void NavigateToCore(string uri, bool forceLoad)
+        protected override void NavigateToCore(string uri, NavigationOptions options)
         {
-            _ipcSender.Navigate(uri, forceLoad);
+            _ipcSender.Navigate(uri, options);
         }
     }
 }

+ 104 - 0
src/Components/test/E2ETest/Tests/RoutingTest.cs

@@ -407,6 +407,110 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             });
         }
 
+        [Fact]
+        public void CanNavigateProgrammaticallyValidateNoReplaceHistoryEntry()
+        {
+            // This test checks if default navigation does not replace Browser history entries
+            SetUrlViaPushState("/");
+
+            var app = Browser.MountTestComponent<TestRouter>();
+            var testSelector = Browser.WaitUntilTestSelectorReady();
+
+            app.FindElement(By.LinkText("Programmatic navigation cases")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            Browser.Contains("programmatic navigation", () => app.FindElement(By.Id("test-info")).Text);
+
+            // We navigate to the /Other page
+            // This will also test our new NavigatTo(string uri) overload (it should not replace the browser history)
+            app.FindElement(By.Id("do-other-navigation")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
+            AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
+
+            // After we press back, we should end up at the "/ProgrammaticNavigationCases" page so we know browser history has not been replaced
+            // If history had been replaced we would have ended up at the "/" page
+            Browser.Navigate().Back();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            AssertHighlightedLinks("Programmatic navigation cases");
+
+            // For completeness, we will test if the normal NavigateTo(string uri, bool forceLoad) overload will also
+            // NOT change the browser's history. So we basically repeat what we have done above.
+            app.FindElement(By.Id("do-other-navigation2")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
+            AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
+
+            Browser.Navigate().Back();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            AssertHighlightedLinks("Programmatic navigation cases");
+
+            // Because this was client-side navigation, we didn't lose the state in the test selector
+            Assert.Equal(typeof(TestRouter).FullName, testSelector.SelectedOption.GetAttribute("value"));
+
+            app.FindElement(By.Id("do-other-navigation-forced")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
+
+            // We check if we had a force load
+            Assert.Throws<StaleElementReferenceException>(() =>
+                testSelector.SelectedOption.GetAttribute("value"));
+
+            // But still we should be able to navigate back, and end up at the "/ProgrammaticNavigationCases" page
+            Browser.Navigate().Back();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            Browser.WaitUntilTestSelectorReady();
+        }
+
+        [Fact]
+        public void CanNavigateProgrammaticallyWithReplaceHistoryEntry()
+        {
+            SetUrlViaPushState("/");
+
+            var app = Browser.MountTestComponent<TestRouter>();
+            var testSelector = Browser.WaitUntilTestSelectorReady();
+
+            app.FindElement(By.LinkText("Programmatic navigation cases")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            Browser.Contains("programmatic navigation", () => app.FindElement(By.Id("test-info")).Text);
+
+            // We navigate to the /Other page, with "replace" enabled
+            app.FindElement(By.Id("do-other-navigation-replacehistoryentry")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
+            AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
+
+            // After we press back, we should end up at the "/" page so we know browser history has been replaced
+            // If history would not have been replaced we would have ended up at the "/ProgrammaticNavigationCases" page
+            Browser.Navigate().Back();
+            Browser.True(() => Browser.Url.EndsWith("/", StringComparison.Ordinal));
+            AssertHighlightedLinks("Default (matches all)", "Default with base-relative URL (matches all)");
+
+            // Because this was all with client-side navigation, we didn't lose the state in the test selector
+            Assert.Equal(typeof(TestRouter).FullName, testSelector.SelectedOption.GetAttribute("value"));
+        }
+
+        [Fact]
+        public void CanNavigateProgrammaticallyWithForceLoadAndReplaceHistoryEntry()
+        {
+            SetUrlViaPushState("/");
+
+            var app = Browser.MountTestComponent<TestRouter>();
+            var testSelector = Browser.WaitUntilTestSelectorReady();
+
+            app.FindElement(By.LinkText("Programmatic navigation cases")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/ProgrammaticNavigationCases", StringComparison.Ordinal));
+            Browser.Contains("programmatic navigation", () => app.FindElement(By.Id("test-info")).Text);
+
+            // We navigate to the /Other page, with replacehistroyentry and forceload enabled
+            app.FindElement(By.Id("do-other-navigation-forced-replacehistoryentry")).Click();
+            Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
+
+            // We check if we had a force load
+            Assert.Throws<StaleElementReferenceException>(() =>
+                testSelector.SelectedOption.GetAttribute("value"));
+
+            // After we press back, we should end up at the "/" page so we know browser history has been replaced
+            Browser.Navigate().Back();
+            Browser.True(() => Browser.Url.EndsWith("/", StringComparison.Ordinal));
+            Browser.WaitUntilTestSelectorReady();
+        }
+
         [Fact]
         public void ClickingAnchorWithNoHrefShouldNotNavigate()
         {

+ 1 - 0
src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor

@@ -22,6 +22,7 @@
     <li><NavLink href="/subdir/LongPage2">Long page 2</NavLink></li>
     <li><NavLink href="/subdir/WithLazyAssembly" id="with-lazy-assembly">With lazy assembly</NavLink></li>
     <li><NavLink href="/subdir/WithLazyLoadedRoutes" id="with-lazy-routes">With lazy loaded routes</NavLink></li>
+    <li><NavLink href="/subdir/ProgrammaticNavigationCases">Programmatic navigation cases</NavLink></li>
     <li><NavLink href="PreventDefaultCases">preventDefault cases</NavLink></li>
     <li><a href="/subdir/images/blazor_logo_1000x.png" download>Download Me</a></li>
     <li><NavLink>Null href never matches</NavLink></li>

+ 24 - 0
src/Components/test/testassets/BasicTestApp/RouterTest/ProgrammaticNavigationCases.razor

@@ -0,0 +1,24 @@
+@page "/ProgrammaticNavigationCases"
+@inject NavigationManager NavigationManager
+
+<div id="test-info">This page has test cases for programmatic navigation.</div>
+
+<button id="do-other-navigation" @onclick="@(() => NavigationManager.NavigateTo("Other", new NavigationOptions()))">
+    Programmatic navigation (NavigationOptions overload)
+</button>
+
+<button id="do-other-navigation2" @onclick="@(() => NavigationManager.NavigateTo("Other", false))">
+    Programmatic navigation (bool overload)
+</button>
+
+<button id="do-other-navigation-forced" @onclick="@(() => NavigationManager.NavigateTo("Other", true))">
+    Programmatic navigation with force-load
+</button>
+
+<button id="do-other-navigation-replacehistoryentry" @onclick="@(() => NavigationManager.NavigateTo("Other", replace: true))">
+    Programmatic navigation with replace
+</button>
+
+<button id="do-other-navigation-forced-replacehistoryentry" @onclick="@(() => NavigationManager.NavigateTo("Other", forceLoad: true, replace: true))">
+    Programmatic navigation with force-load and replace
+</button>

Some files were not shown because too many files changed in this diff