Skip to content

[Blazor] Allow <FocusOnNavigate> to work when rendered statically #57131

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 7 commits into from
Aug 8, 2024
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.webassembly.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.

8 changes: 7 additions & 1 deletion src/Components/Web.JS/src/Boot.Web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel } from './Platform/Logging/Logger';
import { resolveOptions } from './Platform/Circuits/CircuitStartOptions';
import { JSInitializer } from './JSInitializers/JSInitializers';
import { enableFocusOnNavigate } from './Rendering/FocusOnNavigate';

let started = false;
let rootComponentManager: WebRootComponentManager;
Expand All @@ -50,12 +51,16 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
const jsEventRegistry = JSEventRegistry.create(Blazor);

const navigationEnhancementCallbacks: NavigationEnhancementCallbacks = {
enhancedNavigationStarted: () => {
jsEventRegistry.dispatchEvent('enhancednavigationstart', {});
},
documentUpdated: () => {
rootComponentManager.onDocumentUpdated();
jsEventRegistry.dispatchEvent('enhancedload', {});
},
enhancedNavigationCompleted() {
rootComponentManager.onEnhancedNavigationCompleted();
jsEventRegistry.dispatchEvent('enhancednavigationend', {});
},
};

Expand All @@ -66,6 +71,8 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
attachProgressivelyEnhancedNavigationListener(navigationEnhancementCallbacks);
}

enableFocusOnNavigate(jsEventRegistry);

// Wait until the initial page response completes before activating interactive components.
// If stream rendering is used, this helps to ensure that only the final set of interactive
// components produced by the stream render actually get activated for interactivity.
Expand All @@ -79,7 +86,6 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
}

function onInitialDomContentLoaded(options: Partial<WebStartOptions>) {

// Retrieve and start invoking the initializers.
// Blazor server options get defaults that are configured before we invoke the initializers
// so we do the same here.
Expand Down
6 changes: 3 additions & 3 deletions src/Components/Web.JS/src/DomWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import '@microsoft/dotnet-js-interop';

export const domFunctions = {
focus,
focusBySelector
focusBySelector,
};

function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
Expand All @@ -22,7 +22,7 @@ function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
}
}

function focusBySelector(selector: string, preventScroll: boolean): void {
function focusBySelector(selector: string) {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
// If no explicit tabindex is defined, mark it as programmatically-focusable.
Expand All @@ -34,4 +34,4 @@ function focusBySelector(selector: string, preventScroll: boolean): void {

element.focus({ preventScroll: true });
}
}
}
82 changes: 82 additions & 0 deletions src/Components/Web.JS/src/Rendering/FocusOnNavigate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { domFunctions } from '../DomWrapper';
import { JSEventRegistry } from '../Services/JSEventRegistry';
import { isForSamePath } from '../Services/NavigationUtils';

const customElementName = 'blazor-focus-on-navigate';
let currentFocusOnNavigateElement: FocusOnNavigateElement | null = null;
let locationOnLastNavigation = location.href;
let allowApplyFocusAfterEnhancedNavigation = false;

export function enableFocusOnNavigate(jsEventRegistry: JSEventRegistry) {
customElements.define(customElementName, FocusOnNavigateElement);
jsEventRegistry.addEventListener('enhancednavigationstart', onEnhancedNavigationStart);
jsEventRegistry.addEventListener('enhancednavigationend', onEnhancedNavigationEnd);
document.addEventListener('focusin', onFocusIn);

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onInitialPageLoad, { once: true });
} else {
onInitialPageLoad();
}
}

function onInitialPageLoad() {
// On the initial page load, we only want to apply focus if there isn't already
// a focused element.
// See also: https://developer.mozilla.org/docs/Web/API/Document/activeElement#value
if (document.activeElement !== null && document.activeElement !== document.body) {
return;
}

// If an element on the page is requesting autofocus, but hasn't yet been focused,
// we'll respect that.
if (document.querySelector('[autofocus]')) {
return;
}

tryApplyFocus();
}

function onEnhancedNavigationStart() {
// Only move focus when navigating to a new page.
if (!isForSamePath(locationOnLastNavigation, location.href)) {
allowApplyFocusAfterEnhancedNavigation = true;
}

locationOnLastNavigation = location.href;
}

function onEnhancedNavigationEnd() {
if (allowApplyFocusAfterEnhancedNavigation) {
tryApplyFocus();
}
}

function onFocusIn() {
// If the user explicitly focuses a different element before a navigation completes,
// don't move focus again.
allowApplyFocusAfterEnhancedNavigation = false;
}

function tryApplyFocus() {
const selector = currentFocusOnNavigateElement?.getAttribute('selector');
if (selector) {
domFunctions.focusBySelector(selector);
}
}

class FocusOnNavigateElement extends HTMLElement {
connectedCallback() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
currentFocusOnNavigateElement = this;
}

disconnectedCallback() {
if (currentFocusOnNavigateElement === this) {
currentFocusOnNavigateElement = null;
}
}
}
Comment on lines +71 to +82
Copy link
Member Author

Choose a reason for hiding this comment

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

In a previous iteration, I had a slightly more sophisticated implementation that gracefully handled having multiple <FocusOnNavigate> components at the same time, which can be later removed from the page in an arbitrary order without breaking things. However, I realized that's unlikely to be a real scenario, because <FocusOnNavigate> is almost always going to exist in one location (in the router), especially considering it requires a RouteData parameter that you can only obtain from the <Router> (unless you construct it yourself, which I don't think is common).

This simplified implementation always prioritizes the last-rendered <blazor-focus-on-navigate> custom element and assumes that if one already exists, it's going to be disconnected soon because we're in the process of applying updates to the DOM.

If others disagree, I'm happy to add the old logic back. It's really not hugely complicated and I'm confident that it works, but it's just more code that apps need to download (and that we need to maintain).

Copy link
Member

Choose a reason for hiding this comment

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

We could choose to throw if we find more than one instance of focus on navigate on the document on the server even if we wanted.

It's not even clear what the behavior is when many of them are available on the page interactively.

Copy link
Member

@SteveSandersonMS SteveSandersonMS Aug 7, 2024

Choose a reason for hiding this comment

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

TBH I'd be happy with avoiding the need for extra checks/errors and just go with a "first one in the document wins" rule. It would be the same as the HTML autofocus attribute:

image

Copy link
Member

Choose a reason for hiding this comment

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

That's fine for me, given that there's defined behavior.

We should also consider interactions between FocusOnNavigate and autofocus now that you mention it.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've updated the implementation to defer to autofocus on the initial page load. That is, autofocus takes precedence over FocusOnNavigate.

That said, autofocus is not a replacement for FocusOnNavigate, because after the first element gets autofocused, no other elements can get autofocused until a full page reload. In other words, autofocus does not work with enhanced nav.

Copy link
Member

Choose a reason for hiding this comment

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

Sounds reasonable.

6 changes: 4 additions & 2 deletions src/Components/Web.JS/src/Services/JSEventRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ interface BlazorEvent {

// Maps Blazor event names to the argument type passed to registered listeners.
export interface BlazorEventMap {
'enhancedload': BlazorEvent;
'enhancedload': BlazorEvent,
'enhancednavigationstart': BlazorEvent,
'enhancednavigationend': BlazorEvent,
}

export class JSEventRegistry {
Expand Down Expand Up @@ -44,7 +46,7 @@ export class JSEventRegistry {
return;
}

const event: BlazorEventMap[K] = {
const event = {
...ev,
type,
};
Expand Down
28 changes: 9 additions & 19 deletions 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 { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isSamePageWithHash, notifyEnhancedNavigationListners, performScrollToElementOnTheSamePage } from './NavigationUtils';
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, isSamePageWithHash, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage } from './NavigationUtils';

/*
In effect, we have two separate client-side navigation mechanisms:
Expand Down Expand Up @@ -42,6 +42,7 @@ let performingEnhancedPageLoad: boolean;
let currentContentUrl = location.href;

export interface NavigationEnhancementCallbacks {
enhancedNavigationStarted: () => void;
documentUpdated: () => void;
enhancedNavigationCompleted: () => void;
}
Expand Down Expand Up @@ -167,7 +168,7 @@ function onDocumentSubmit(event: SubmitEvent) {
fetchOptions.headers = {
'content-type': enctype,
// Setting Accept header here as well so it wouldn't be lost when coping headers
'accept': acceptHeader
'accept': acceptHeader,
};
}
}
Expand All @@ -183,7 +184,10 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
currentEnhancedNavigationAbortController?.abort();

// Notify any interactive runtimes that an enhanced navigation is starting
notifyEnhancedNavigationListners(internalDestinationHref, interceptedLink);
notifyEnhancedNavigationListeners(internalDestinationHref, interceptedLink);

// Notify handlers that enhanced navigation is starting
navigationEnhancementCallbacks.enhancedNavigationStarted();

// Now request the new page via fetch, and a special header that tells the server we want it to inject
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
Expand Down Expand Up @@ -262,12 +266,12 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i

if (!response.redirected && !isGetRequest && isSuccessResponse) {
// If this is the result of a form post that didn't trigger a redirection.
if (!isForSamePath(response)) {
if (!isForSamePath(response.url, currentContentUrl)) {
// In this case we don't want to push the currentContentUrl to the history stack because we don't know if this is a location
// we can navigate back to (as we don't know if the location supports GET) and we are not able to replicate the Resubmit form?
// browser behavior.
// The only case where this is acceptable is when the last content URL, is the same as the URL for the form we posted to.
isNonRedirectedPostToADifferentUrlMessage = `Cannot perform enhanced form submission that changes the URL (except via a redirection), because then back/forward would not work. Either remove this form\'s \'action\' attribute, or change its method to \'get\', or do not mark it as enhanced.\nOld URL: ${location.href}\nNew URL: ${response.url}`;
isNonRedirectedPostToADifferentUrlMessage = `Cannot perform enhanced form submission that changes the URL (except via a redirection), because then back/forward would not work. Either remove this form's 'action' attribute, or change its method to 'get', or do not mark it as enhanced.\nOld URL: ${location.href}\nNew URL: ${response.url}`;
} else {
if (location.href !== currentContentUrl) {
// The url on the browser might be out of data, so push an entry to the stack to update the url in place.
Expand Down Expand Up @@ -335,20 +339,6 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
throw new Error(isNonRedirectedPostToADifferentUrlMessage);
}
}

function isForSamePath(response: Response) {
// We are trying to determine if the response URL is compatible with the last content URL that was successfully loaded on to
// the page.
// We are going to use the scheme, host, port and path to determine if they are compatible. We do not account for the query string
// as we want to allow for the query string to change. (Blazor doesn't use the query string for routing purposes).

const responseUrl = new URL(response.url);
const currentContentUrlParsed = new URL(currentContentUrl!);
return responseUrl.protocol === currentContentUrlParsed.protocol
&& responseUrl.host === currentContentUrlParsed.host
&& responseUrl.port === currentContentUrlParsed.port
&& responseUrl.pathname === currentContentUrlParsed.pathname;
}
}

async function getResponsePartsWithFraming(responsePromise: Promise<Response>, abortSignal: AbortSignal, onInitialDocument: (response: Response, initialDocumentText: string) => void, onStreamingElement: (streamingElementMarkup) => void) {
Expand Down
16 changes: 14 additions & 2 deletions src/Components/Web.JS/src/Services/NavigationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { WebRendererId } from '../Rendering/WebRendererId';

let interactiveRouterRendererId: WebRendererId | undefined = undefined;
let programmaticEnhancedNavigationHandler: typeof performProgrammaticEnhancedNavigation | undefined;
let enhancedNavigationListener: typeof notifyEnhancedNavigationListners | undefined;
let enhancedNavigationListener: typeof notifyEnhancedNavigationListeners | 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 @@ -52,6 +52,18 @@ export function isSamePageWithHash(absoluteHref: string): boolean {
return url.hash !== '' && location.origin === url.origin && location.pathname === url.pathname && location.search === url.search;
}

export function isForSamePath(url1: string, url2: string) {
// We are going to use the scheme, host, port and path to determine if the two URLs are compatible.
// We do not account for the query string as we want to allow for the query string to change.
// (Blazor doesn't use the query string for routing purposes).
const parsedUrl1 = new URL(url1);
const parsedUrl2 = new URL(url2);
return parsedUrl1.protocol === parsedUrl2.protocol
&& parsedUrl1.host === parsedUrl2.host
&& parsedUrl1.port === parsedUrl2.port
&& parsedUrl1.pathname === parsedUrl2.pathname;
}

export function performScrollToElementOnTheSamePage(absoluteHref : string): void {
const hashIndex = absoluteHref.indexOf('#');
if (hashIndex === absoluteHref.length - 1) {
Expand All @@ -70,7 +82,7 @@ export function attachEnhancedNavigationListener(listener: typeof enhancedNaviga
enhancedNavigationListener = listener;
}

export function notifyEnhancedNavigationListners(internalDestinationHref: string, interceptedLink: boolean) {
export function notifyEnhancedNavigationListeners(internalDestinationHref: string, interceptedLink: boolean) {
enhancedNavigationListener?.(internalDestinationHref, interceptedLink);
}

Expand Down
1 change: 1 addition & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.Invo
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.get -> bool
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.set -> void
override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.RendererInfo.get -> Microsoft.AspNetCore.Components.RendererInfo!
override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void
21 changes: 21 additions & 0 deletions src/Components/Web/src/Routing/FocusOnNavigate.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components.Routing;
Expand All @@ -12,6 +13,8 @@ namespace Microsoft.AspNetCore.Components.Routing;
/// </summary>
public class FocusOnNavigate : ComponentBase
{
private const string CustomElementName = "blazor-focus-on-navigate";

private Type? _lastNavigatedPageType = typeof(NonMatchingType);
private bool _focusAfterRender;

Expand Down Expand Up @@ -50,6 +53,24 @@ protected override void OnParametersSet()
}
}

/// <inheritdoc/>
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (AssignedRenderMode is not null)
{
// When interactivity is enabled, functionality is handled via JS interop.
// We don't need to render anything to the page in that case.
// In non-interactive scenarios, a custom element is rendered so that
// JS logic can find it and focus the element matching the specified
// selector.
return;
}

builder.OpenElement(0, CustomElementName);
builder.AddAttribute(1, "selector", Selector);
builder.CloseElement();
}

/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
Expand Down
Loading
Loading