Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ public event EventHandler<LocationChangedEventArgs> LocationChanged

// The URI. Always represented an absolute URI.
private string? _uri;

private bool _isInitialized;

/// <summary>
Expand Down Expand Up @@ -85,6 +84,14 @@ protected set
}
}

/// <summary>
/// Gets or sets the state associated with the current navigation.
/// </summary>
/// <remarks>
/// Setting <see cref="HistoryEntryState" /> will not trigger the <see cref="LocationChanged" /> event.
/// </remarks>
public string? HistoryEntryState { get; protected set; }

/// <summary>
/// Navigates to the specified URI.
/// </summary>
Expand Down Expand Up @@ -254,7 +261,12 @@ protected void NotifyLocationChanged(bool isInterceptedLink)
{
try
{
_locationChanged?.Invoke(this, new LocationChangedEventArgs(_uri!, isInterceptedLink));
_locationChanged?.Invoke(
this,
new LocationChangedEventArgs(_uri!, isInterceptedLink)
{
HistoryEntryState = HistoryEntryState
});
}
catch (Exception ex)
{
Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/src/NavigationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ public readonly struct NavigationOptions
/// If false, appends the new entry to the history stack.
/// </summary>
public bool ReplaceHistoryEntry { get; init; }

/// <summary>
/// Gets or sets the state to append to the history entry.
/// </summary>
public string? HistoryEntryState { get; init; }
}
5 changes: 5 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.set -> void
Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.init -> void
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddContent(int sequence, Microsoft.AspNetCore.Components.MarkupString? markupContent) -> void
Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs.HistoryEntryState.get -> string?
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback<T>(object! receiver, Microsoft.AspNetCore.Components.EventCallback<T> callback, T value) -> Microsoft.AspNetCore.Components.EventCallback<T>
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate(System.Action! callback) -> System.Threading.Tasks.Task!
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate(System.Func<System.Threading.Tasks.Task!>! callback) -> System.Threading.Tasks.Task!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ public LocationChangedEventArgs(string location, bool isNavigationIntercepted)
/// Gets a value that determines if navigation for the link was intercepted.
/// </summary>
public bool IsNavigationIntercepted { get; }

/// <summary>
/// Gets the state associated with the current history entry.
/// </summary>
public string? HistoryEntryState { get; internal init; }
}
2 changes: 1 addition & 1 deletion src/Components/Components/test/Routing/RouterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ internal class TestNavigationManager : NavigationManager
public TestNavigationManager() =>
Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan");

public void NotifyLocationChanged(string uri, bool intercepted)
public void NotifyLocationChanged(string uri, bool intercepted, string state = null)
{
Uri = uri;
NotifyLocationChanged(intercepted);
Expand Down
4 changes: 2 additions & 2 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ public async Task<DotNetStreamReference> TryClaimPendingStream(long streamId)

// OnLocationChangedAsync is used in a fire-and-forget context, so it's responsible for its own
// error handling.
public async Task OnLocationChangedAsync(string uri, bool intercepted)
public async Task OnLocationChangedAsync(string uri, string state, bool intercepted)
{
AssertInitialized();
AssertNotDisposed();
Expand All @@ -504,7 +504,7 @@ await Renderer.Dispatcher.InvokeAsync(() =>
{
Log.LocationChange(_logger, uri, CircuitId);
var navigationManager = (RemoteNavigationManager)Services.GetRequiredService<NavigationManager>();
navigationManager.NotifyLocationChanged(uri, intercepted);
navigationManager.NotifyLocationChanged(uri, state, intercepted);
Log.LocationChangeSucceeded(_logger, uri, CircuitId);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ public void AttachJsRuntime(IJSRuntime jsRuntime)
_jsRuntime = jsRuntime;
}

public void NotifyLocationChanged(string uri, bool intercepted)
public void NotifyLocationChanged(string uri, string state, bool intercepted)
{
Log.ReceivedLocationChangedNotification(_logger, uri, intercepted);

Uri = uri;
HistoryEntryState = state;
NotifyLocationChanged(intercepted);
}

Expand Down
4 changes: 2 additions & 2 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,15 @@ public async ValueTask OnRenderCompleted(long renderId, string errorMessageOrNul
_ = circuitHost.OnRenderCompletedAsync(renderId, errorMessageOrNull);
}

public async ValueTask OnLocationChanged(string uri, bool intercepted)
public async ValueTask OnLocationChanged(string uri, string? state, bool intercepted)
{
var circuitHost = await GetActiveCircuitAsync();
if (circuitHost == null)
{
return;
}

_ = circuitHost.OnLocationChangedAsync(uri, intercepted);
_ = circuitHost.OnLocationChangedAsync(uri, state, intercepted);
}

// We store the CircuitHost through a *handle* here because Context.Items is tied to the lifetime
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Server/test/Circuits/ComponentHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public async Task CannotInvokeOnLocationChangedBeforeInitialization()
{
var (mockClientProxy, hub) = InitializeComponentHub();

await hub.OnLocationChanged("https://localhost:5000/subdir/page", false);
await hub.OnLocationChanged("https://localhost:5000/subdir/page", null, false);

var errorMessage = "Circuit not initialized.";
mockClientProxy.Verify(m => m.SendCoreAsync("JS.Error", new[] { errorMessage }, It.IsAny<CancellationToken>()), Times.Once());
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ async function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {
const circuit = new CircuitDescriptor(components, appState || '');

// Configure navigation via SignalR
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanged', uri, intercepted);
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanged', uri, state, intercepted);
});

Blazor._internal.forceCloseConnection = () => connection.stop();
Expand Down
3 changes: 2 additions & 1 deletion src/Components/Web.JS/src/Boot.WebAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,12 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
Blazor._internal.navigationManager.getUnmarshalledBaseURI = () => BINDING.js_string_to_mono_string(getBaseUri());
Blazor._internal.navigationManager.getUnmarshalledLocationHref = () => BINDING.js_string_to_mono_string(getLocationHref());

Blazor._internal.navigationManager.listenForNavigationEvents(async (uri: string, intercepted: boolean): Promise<void> => {
Blazor._internal.navigationManager.listenForNavigationEvents(async (uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
await DotNet.invokeMethodAsync(
'Microsoft.AspNetCore.Components.WebAssembly',
'NotifyLocationChanged',
uri,
state,
intercepted
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ function base64EncodeByteArray(data: Uint8Array) {
return dataBase64Encoded;
}

export function sendLocationChanged(uri: string, intercepted: boolean): Promise<void> {
send('OnLocationChanged', uri, intercepted);
export function sendLocationChanged(uri: string, state: string | undefined, intercepted: boolean): Promise<void> {
send('OnLocationChanged', uri, state, intercepted);
return Promise.resolve(); // Like in Blazor Server, we only issue the notification here - there's no need to wait for a response
}

Expand Down
15 changes: 8 additions & 7 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ let hasEnabledNavigationInterception = false;
let hasRegisteredNavigationEventListeners = false;

// Will be initialized once someone registers
let notifyLocationChangedCallback: ((uri: string, intercepted: boolean) => Promise<void>) | null = null;
let notifyLocationChangedCallback: ((uri: string, state: string | undefined, intercepted: boolean) => Promise<void>) | null = null;

// These are the functions we're making available for invocation from .NET
export const internalFunctions = {
Expand All @@ -20,7 +20,7 @@ export const internalFunctions = {
getLocationHref: (): string => location.href,
};

function listenForNavigationEvents(callback: (uri: string, intercepted: boolean) => Promise<void>): void {
function listenForNavigationEvents(callback: (uri: string, state: string | undefined, intercepted: boolean) => Promise<void>): void {
notifyLocationChangedCallback = callback;

if (hasRegisteredNavigationEventListeners) {
Expand Down Expand Up @@ -82,7 +82,7 @@ export function navigateTo(uri: string, forceLoadOrOptions: NavigationOptions |
: { forceLoad: forceLoadOrOptions, replaceHistoryEntry: replaceIfUsingOldOverload };

if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) {
performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry);
performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState);
} 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
Expand All @@ -107,7 +107,7 @@ function performExternalNavigation(uri: string, replace: boolean) {
}
}

function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean) {
function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean, state: string | undefined = undefined) {
// 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.
Expand All @@ -116,17 +116,17 @@ function performInternalNavigation(absoluteInternalHref: string, interceptedLink
resetScrollAfterNextBatch();

if (!replace) {
history.pushState(null, /* ignored title */ '', absoluteInternalHref);
history.pushState(state, /* ignored title */ '', absoluteInternalHref);
} else {
history.replaceState(null, /* ignored title */ '', absoluteInternalHref);
history.replaceState(state, /* ignored title */ '', absoluteInternalHref);
}

notifyLocationChanged(interceptedLink);
}

async function notifyLocationChanged(interceptedLink: boolean) {
if (notifyLocationChangedCallback) {
await notifyLocationChangedCallback(location.href, interceptedLink);
await notifyLocationChangedCallback(location.href, history.state, interceptedLink);
}
}

Expand Down Expand Up @@ -195,4 +195,5 @@ function canProcessAnchor(anchorTarget: HTMLAnchorElement) {
export interface NavigationOptions {
forceLoad: boolean;
replaceHistoryEntry: boolean;
historyEntryState?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ public static class JSInteropMethods
/// <summary>
/// For framework use only.
/// </summary>
[JSInvokable(nameof(NotifyLocationChanged))]
[Obsolete("This API is for framework use only and is no longer used in the current version")]
public static void NotifyLocationChanged(string uri, bool isInterceptedLink)
=> WebAssemblyNavigationManager.Instance.SetLocation(uri, null, isInterceptedLink);

/// <summary>
/// For framework use only.
/// </summary>
[JSInvokable(nameof(NotifyLocationChanged))]
public static void NotifyLocationChanged(string uri, string? state, bool isInterceptedLink)
{
WebAssemblyNavigationManager.Instance.SetLocation(uri, isInterceptedLink);
WebAssemblyNavigationManager.Instance.SetLocation(uri, state, isInterceptedLink);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#nullable enable
*REMOVED*static Microsoft.AspNetCore.Components.WebAssembly.HotReload.WebAssemblyHotReload.ApplyHotReloadDelta(string! moduleIdString, byte[]! metadataDelta, byte[]! ilDeta) -> void
static Microsoft.AspNetCore.Components.WebAssembly.HotReload.WebAssemblyHotReload.ApplyHotReloadDelta(string! moduleIdString, byte[]! metadataDelta, byte[]! ilDelta, byte[]! pdbBytes) -> void
static Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.NotifyLocationChanged(string! uri, string? state, bool isInterceptedLink) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ public WebAssemblyNavigationManager(string baseUri, string uri)
Initialize(baseUri, uri);
}

public void SetLocation(string uri, bool isInterceptedLink)
public void SetLocation(string uri, string? state, bool isInterceptedLink)
{
Uri = uri;
HistoryEntryState = state;
NotifyLocationChanged(isInterceptedLink);
}

Expand Down
6 changes: 3 additions & 3 deletions src/Components/WebView/WebView/src/IpcReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public async Task OnMessageReceivedAsync(PageContext pageContext, string message
OnRenderCompleted(pageContext, args[0].GetInt64(), args[1].GetString());
break;
case IpcCommon.IncomingMessageType.OnLocationChanged:
OnLocationChanged(pageContext, args[0].GetString(), args[1].GetBoolean());
OnLocationChanged(pageContext, args[0].GetString(), args[1].GetString(), args[2].GetBoolean());
break;
default:
throw new InvalidOperationException($"Unknown message type '{messageType}'.");
Expand Down Expand Up @@ -97,8 +97,8 @@ private static void OnRenderCompleted(PageContext pageContext, long batchId, str
pageContext.Renderer.NotifyRenderCompleted(batchId);
}

private static void OnLocationChanged(PageContext pageContext, string uri, bool intercepted)
private static void OnLocationChanged(PageContext pageContext, string uri, string? state, bool intercepted)
{
pageContext.NavigationManager.LocationUpdated(uri, intercepted);
pageContext.NavigationManager.LocationUpdated(uri, state, intercepted);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ public void AttachToWebView(IpcSender ipcSender, string baseUrl, string initialU
Initialize(baseUrl, initialUrl);
}

public void LocationUpdated(string newUrl, bool intercepted)
public void LocationUpdated(string newUrl, string? state, bool intercepted)
{
Uri = newUrl;
HistoryEntryState = state;
NotifyLocationChanged(intercepted);
}

Expand Down
70 changes: 69 additions & 1 deletion src/Components/test/E2ETest/Tests/RoutingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,74 @@ public void CanNavigateProgrammaticallyWithForceLoad()
});
}

[Fact]
public void CanNavigateProgrammaticallyWithStateValidateNoReplaceHistoryEntry()
{
// 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
app.FindElement(By.Id("do-other-navigation-state")).Click();
Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
Browser.Contains("state", () => app.FindElement(By.Id("test-state")).Text);
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");

// When the navigation is forced, the state is ignored (we could choose to throw here).
app.FindElement(By.Id("do-other-navigation-forced-state")).Click();
Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
Browser.DoesNotExist(By.Id("test-state"));

// 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 CanNavigateProgrammaticallyWithStateReplaceHistoryEntry()
{
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-state-replacehistoryentry")).Click();
Browser.True(() => Browser.Url.EndsWith("/Other", StringComparison.Ordinal));
Browser.Contains("state", () => app.FindElement(By.Id("test-state")).Text);
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 CanNavigateProgrammaticallyValidateNoReplaceHistoryEntry()
{
Expand All @@ -452,7 +520,7 @@ public void CanNavigateProgrammaticallyValidateNoReplaceHistoryEntry()
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)
// This will also test our new NavigateTo(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)");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
@page "/Other"
@inject NavigationManager Navigation
<div id="test-info">This is another page.</div>
<div id="test-state">@Navigation.HistoryEntryState</div>
Loading