Skip to content

Enable navigation when RenderModeServer #49768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 10, 2023
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
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.web.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.

18 changes: 17 additions & 1 deletion src/Components/Web.JS/src/Services/NavigationEnhancement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync';
import { handleClickForNavigationInterception, hasInteractiveRouter } from './NavigationUtils';
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter } from './NavigationUtils';

/*
In effect, we have two separate client-side navigation mechanisms:
Expand Down Expand Up @@ -56,6 +56,22 @@ export function detachProgressivelyEnhancedNavigationListener() {
window.removeEventListener('popstate', onPopState);
}

function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, replace: boolean) {
if (hasInteractiveRouter()) {
return;
}

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

performEnhancedPageLoad(absoluteInternalHref);
}

attachProgrammaticEnhancedNavigationHandler(performProgrammaticEnhancedNavigation);

function onDocumentClick(event: MouseEvent) {
if (hasInteractiveRouter()) {
return;
Expand Down
14 changes: 11 additions & 3 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import '@microsoft/dotnet-js-interop';
import { resetScrollAfterNextBatch } from '../Rendering/Renderer';
import { EventDelegator } from '../Rendering/Events/EventDelegator';
import { handleClickForNavigationInterception, hasInteractiveRouter, isWithinBaseUriSpace, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils';
import { handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils';

let hasRegisteredNavigationEventListeners = false;
let hasLocationChangingEventListeners = false;
Expand Down Expand Up @@ -116,7 +116,11 @@ function navigateToCore(uri: string, options: NavigationOptions, skipLocationCha
const absoluteUri = toAbsoluteUri(uri);

if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) {
performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState, skipLocationChangingCallback);
if (shouldUseClientSideRouting()) {
performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState, skipLocationChangingCallback);
} else {
performProgrammaticEnhancedNavigation(absoluteUri, 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
Expand Down Expand Up @@ -255,13 +259,17 @@ async function notifyLocationChanged(interceptedLink: boolean) {
}

async function onPopState(state: PopStateEvent) {
if (popStateCallback) {
if (popStateCallback && shouldUseClientSideRouting()) {
await popStateCallback(state);
}

currentHistoryIndex = history.state?._index ?? 0;
}

function shouldUseClientSideRouting() {
return hasInteractiveRouter() || !hasProgrammaticEnhancedNavigationHandler();
}

// Keep in sync with Components/src/NavigationOptions.cs
export interface NavigationOptions {
forceLoad: boolean;
Expand Down
17 changes: 17 additions & 0 deletions src/Components/Web.JS/src/Services/NavigationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

let hasInteractiveRouterValue = false;
let programmaticEnhancedNavigationHandler: typeof performProgrammaticEnhancedNavigation | undefined;

/**
* Checks if a click event corresponds to an <a> tag referencing a URL within the base href, and that interception
Expand Down Expand Up @@ -43,6 +44,22 @@ export function isWithinBaseUriSpace(href: string) {
&& (nextChar === '' || nextChar === '/' || nextChar === '?' || nextChar === '#');
}

export function hasProgrammaticEnhancedNavigationHandler(): boolean {
return programmaticEnhancedNavigationHandler !== undefined;
}

export function attachProgrammaticEnhancedNavigationHandler(handler: typeof programmaticEnhancedNavigationHandler) {
programmaticEnhancedNavigationHandler = handler;
}

export function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, replace: boolean): void {
if (!programmaticEnhancedNavigationHandler) {
throw new Error('No enhanced programmatic navigation handler has been attached');
}

programmaticEnhancedNavigationHandler(absoluteInternalHref, replace);
}
Comment on lines +47 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This dance is to avoid NavigationEnhancement.ts and NavigationManager.ts from importing each other. That way, blazor.server.js and blazor.webassembly.js don't pay the cost of SSR features that they don't use.


function toBaseUriWithoutTrailingSlash(baseUri: string) {
return baseUri.substring(0, baseUri.lastIndexOf('/'));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;

[CollectionDefinition(nameof(EnhancedNavigationTest), DisableParallelization = true)]
public class EnhancedNavigationTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
{
public EnhancedNavigationTest(
Expand Down Expand Up @@ -144,9 +145,127 @@ public void CanFollowAsynchronousExternalRedirectionWhileStreaming()
Browser.Contains("microsoft.com", () => Browser.Url);
}

[Theory]
[InlineData("server")]
[InlineData("webassembly")]
public void CanPerformProgrammaticEnhancedNavigation(string renderMode)
{
Navigate($"{ServerPathBase}/nav");
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);

// Normally, you shouldn't store references to elements because they could become stale references
// after the page re-renders. However, we want to explicitly test that the element persists across
// renders to ensure that enhanced navigation occurs instead of a full page reload.
// Here, we pick an element that we know will persist across navigations so we can check
// for its staleness.
var elementForStalenessCheck = Browser.Exists(By.TagName("html"));

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText($"Interactive component navigation ({renderMode})")).Click();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);
Browser.False(() => IsElementStale(elementForStalenessCheck));

Browser.Exists(By.Id("navigate-to-another-page")).Click();
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
Assert.EndsWith("/nav", Browser.Url);
Browser.False(() => IsElementStale(elementForStalenessCheck));

// Ensure that the history stack was correctly updated
Browser.Navigate().Back();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);
Browser.False(() => IsElementStale(elementForStalenessCheck));

Browser.Navigate().Back();
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
Assert.EndsWith("/nav", Browser.Url);
Browser.False(() => IsElementStale(elementForStalenessCheck));
}

[Theory]
[InlineData("server")]
[InlineData("webassembly")]
public void CanPerformProgrammaticEnhancedRefresh(string renderMode)
{
Navigate($"{ServerPathBase}/nav");
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText($"Interactive component navigation ({renderMode})")).Click();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);

// Normally, you shouldn't store references to elements because they could become stale references
// after the page re-renders. However, we want to explicitly test that the element persists across
// renders to ensure that enhanced navigation occurs instead of a full page reload.
var renderIdElement = Browser.Exists(By.Id("render-id"));
var initialRenderId = -1;
Browser.True(() => int.TryParse(renderIdElement.Text, out initialRenderId));
Assert.NotEqual(-1, initialRenderId);

Browser.Exists(By.Id("perform-enhanced-refresh")).Click();
Browser.True(() =>
{
if (IsElementStale(renderIdElement) || !int.TryParse(renderIdElement.Text, out var newRenderId))
{
return false;
}

return newRenderId > initialRenderId;
});

// Ensure that the history stack was correctly updated
Browser.Navigate().Back();
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
Assert.EndsWith("/nav", Browser.Url);
}

[Theory]
[InlineData("server")]
[InlineData("webassembly")]
public void NavigateToCanFallBackOnFullPageReload(string renderMode)
{
Navigate($"{ServerPathBase}/nav");
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText($"Interactive component navigation ({renderMode})")).Click();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);

// Normally, you shouldn't store references to elements because they could become stale references
// after the page re-renders. However, we want to explicitly test that the element becomes stale
// across renders to ensure that a full page reload occurs.
var initialRenderIdElement = Browser.Exists(By.Id("render-id"));
var initialRenderId = -1;
Browser.True(() => int.TryParse(initialRenderIdElement.Text, out initialRenderId));
Assert.NotEqual(-1, initialRenderId);

Browser.Exists(By.Id("perform-page-reload")).Click();
Browser.True(() => IsElementStale(initialRenderIdElement));

var finalRenderIdElement = Browser.Exists(By.Id("render-id"));
var finalRenderId = -1;
Browser.True(() => int.TryParse(finalRenderIdElement.Text, out finalRenderId));
Assert.NotEqual(-1, initialRenderId);
Assert.True(finalRenderId > initialRenderId);

// Ensure that the history stack was correctly updated
Browser.Navigate().Back();
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
Assert.EndsWith("/nav", Browser.Url);
}

private long BrowserScrollY
{
get => Convert.ToInt64(((IJavaScriptExecutor)Browser).ExecuteScript("return window.scrollY"), CultureInfo.CurrentCulture);
set => ((IJavaScriptExecutor)Browser).ExecuteScript($"window.scrollTo(0, {value})");
}

private static bool IsElementStale(IWebElement element)
{
try
{
_ = element.Enabled;
return false;
}
catch (StaleElementReferenceException)
{
return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@page "/nav/interactive-component-navigation/{renderMode}"
@using TestContentPackage;
@inject NavigationManager Nav

@{
_renderId++;
}

<h1>Page with interactive components that navigate</h1>

<p>
SSR render ID: <span id="render-id">@_renderId</span>
</p>

@if (_renderMode is not null)
{
<InteractiveNavigationComponent @rendermode="@_renderMode" />
}
else
{
<text>Invalid render mode "@RenderMode"</text>
}

@code {
private IComponentRenderMode? _renderMode;
private static int _renderId;

[Parameter]
public string RenderMode { get; set; }

protected override void OnInitialized()
{
if (string.Equals("server", RenderMode, StringComparison.OrdinalIgnoreCase))
{
_renderMode = new ServerRenderMode(prerender: false);
}
else if (string.Equals("webassembly", RenderMode, StringComparison.OrdinalIgnoreCase))
{
_renderMode = new WebAssemblyRenderMode(prerender: false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<NavLink href="nav/do-redirection-while-streaming">Redirect while streaming</NavLink> |
<NavLink href="nav/do-redirection-while-streaming?destination=https://microsoft.com">Redirect external while streaming</NavLink> |
<NavLink href="nav/throw-while-streaming">Error while streaming</NavLink>
<NavLink href="nav/interactive-component-navigation/server">Interactive component navigation (server)</NavLink>
<NavLink href="nav/interactive-component-navigation/webassembly">Interactive component navigation (webassembly)</NavLink>
</nav>
<hr/>
@Body
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@inject NavigationManager Navigation

<button type="button" id="navigate-to-another-page" @onclick="NavigateToAnotherPage">Navigate to another page</button>
<br />
<button type="button" id="perform-enhanced-refresh" @onclick="PerformEnhancedRefresh">Perform enhanced refresh</button>
<br />
<button type="button" id="perform-page-reload" @onclick="PerformPageReload">Perform page reload</button>

@code {
private void NavigateToAnotherPage()
{
Navigation.NavigateTo("nav");
}

private void PerformEnhancedRefresh()
{
Navigation.NavigateTo(Navigation.Uri, replace: true);
}

private void PerformPageReload()
{
Navigation.NavigateTo(Navigation.Uri, forceLoad: true, replace: true);
}
}