Skip to content

Commit 844e28b

Browse files
[release/8.0] [Blazor] Fix for URLs/querystrings not updating for interactive components when using SSR routing (#51279)
# Invoke `LocationChanged` for interactive components when an enhanced navigation occurs This PR makes the `LocationChanged` event get invoked for interactive runtimes when an enhanced navigation occurs. ## Description This PR makes the following changes: * The `LocationChanged` event gets invoked for all interactive runtimes when either a client-side or enhanced navigation occurs * Previously: * An enhanced navigation would have not invoked the `LocationChanged` event in interactive runtimes * A client-side navigation would only invoke the `LocationChanged` event for the runtime that started most recently * Attempting to enable interactive routing from multiple interactive runtimes results in an error at runtime * If an interactive router is present but multiple runtimes are available, the `LocationChanging` event only gets invoked for the runtime that has the interactive router * It would take additional work to allow either runtime to cancel a client-side navigation, and that's outside the scope of this PR. We have #48766 to track doing the extra work to support this scenario Fixes #51216 ## Customer Impact Without this changes the customers will encounter problems with UI being correctly updated during enhanced navigation because `LocationChanging` event not firing. Besides the UI not updating correctly this will also cause customer registered handlers for the `LocationChanged` event not firing resulting in unexpected behaviors. ## Regression? - [ ] Yes - [x] No [If yes, specify the version the behavior has regressed from] ## Risk - [ ] High - [ ] Medium - [x] Low Despite the size of this change, we have quite extensive automated tests (including these newly added ones) around the interactive routing scenarios, so we expect that nothing will break from this change. ## Verification - [x] Manual (required) - [x] Automated ## Packaging changes reviewed? - [ ] Yes - [x] No - [ ] N/A ---- ## When servicing release/2.1 - [ ] Make necessary changes in eng/PatchConfig.props --------- Co-authored-by: Surayya Huseyn Zada <[email protected]> Co-authored-by: Surayya Huseyn Zada <[email protected]>
1 parent eec5dc1 commit 844e28b

22 files changed

+354
-62
lines changed

src/Components/Server/src/Circuits/RemoteNavigationInterception.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,6 @@ public async Task EnableNavigationInterceptionAsync()
3535
"attempted during prerendering or while the client is disconnected.");
3636
}
3737

38-
await _jsRuntime.InvokeAsync<object>(Interop.EnableNavigationInterception);
38+
await _jsRuntime.InvokeAsync<object>(Interop.EnableNavigationInterception, WebRendererId.Server);
3939
}
4040
}

src/Components/Server/src/Circuits/RemoteNavigationManager.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ private async Task SetHasLocationChangingListenersAsync(bool value)
157157
{
158158
try
159159
{
160-
await _jsRuntime.InvokeVoidAsync(Interop.SetHasLocationChangingListeners, value);
160+
await _jsRuntime.InvokeVoidAsync(Interop.SetHasLocationChangingListeners, WebRendererId.Server, value);
161161
}
162162
catch (JSDisconnectedException)
163163
{

src/Components/Web.JS/dist/Release/blazor.server.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.web.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Boot.Server.Common.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnect
1010
import { discoverServerPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
1111
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.Server';
1212
import { RootComponentManager } from './Services/RootComponentManager';
13+
import { WebRendererId } from './Rendering/WebRendererId';
1314

1415
let initializersPromise: Promise<void> | undefined;
1516
let appState: string;
@@ -74,7 +75,7 @@ async function startServerCore(components: RootComponentManager<ServerComponentD
7475
options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
7576

7677
// Configure navigation via SignalR
77-
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
78+
Blazor._internal.navigationManager.listenForNavigationEvents(WebRendererId.Server, (uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
7879
return circuit.sendLocationChanged(uri, state, intercepted);
7980
}, (callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
8081
return circuit.sendLocationChanging(callId, uri, state, intercepted);

src/Components/Web.JS/src/Boot.WebAssembly.Common.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { receiveDotNetDataStream } from './StreamingInterop';
1616
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
1717
import { MonoConfig } from 'dotnet';
1818
import { RootComponentManager } from './Services/RootComponentManager';
19+
import { WebRendererId } from './Rendering/WebRendererId';
1920

2021
let options: Partial<WebAssemblyStartOptions> | undefined;
2122
let initializersPromise: Promise<void>;
@@ -127,7 +128,7 @@ async function startCore(components: RootComponentManager<WebAssemblyComponentDe
127128
}
128129
};
129130

130-
Blazor._internal.navigationManager.listenForNavigationEvents(async (uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
131+
Blazor._internal.navigationManager.listenForNavigationEvents(WebRendererId.WebAssembly, async (uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
131132
await dispatcher.invokeDotNetStaticMethodAsync(
132133
'Microsoft.AspNetCore.Components.WebAssembly',
133134
'NotifyLocationChanged',

src/Components/Web.JS/src/Boot.WebView.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver';
99
import { sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendByteArray, sendLocationChanged, sendLocationChanging } from './Platform/WebView/WebViewIpcSender';
1010
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.WebView';
1111
import { receiveDotNetDataStream } from './StreamingInterop';
12+
import { WebRendererId } from './Rendering/WebRendererId';
1213

1314
let started = false;
1415

@@ -32,8 +33,8 @@ async function boot(): Promise<void> {
3233

3334
Blazor._internal.receiveWebViewDotNetDataStream = receiveWebViewDotNetDataStream;
3435

35-
navigationManagerFunctions.enableNavigationInterception();
36-
navigationManagerFunctions.listenForNavigationEvents(sendLocationChanged, sendLocationChanging);
36+
navigationManagerFunctions.enableNavigationInterception(WebRendererId.WebView);
37+
navigationManagerFunctions.listenForNavigationEvents(WebRendererId.WebView, sendLocationChanged, sendLocationChanging);
3738

3839
sendAttachPage(navigationManagerFunctions.getBaseURI(), navigationManagerFunctions.getLocationHref());
3940
await jsInitializer.invokeAfterStartedCallbacks(Blazor);

src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export function startIpcReceiver(): void {
4343

4444
'Refresh': navigationManagerFunctions.refresh,
4545

46-
'SetHasLocationChangingListeners': navigationManagerFunctions.setHasLocationChangingListeners,
46+
'SetHasLocationChangingListeners': (hasListeners: boolean) => {
47+
navigationManagerFunctions.setHasLocationChangingListeners(WebRendererId.WebView, hasListeners);
48+
},
4749

4850
'EndLocationChanging': navigationManagerFunctions.endLocationChanging,
4951
};

src/Components/Web.JS/src/Rendering/StreamingRendering.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class BlazorStreamingUpdate extends HTMLElement {
6060
// The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location.
6161
history.replaceState(null, '', destinationUrl);
6262
}
63-
performEnhancedPageLoad(destinationUrl);
63+
performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false);
6464
} else {
6565
// Same reason for varying as above
6666
if (isFormPost) {

src/Components/Web.JS/src/Services/NavigationEnhancement.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync';
5-
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter } from './NavigationUtils';
5+
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, notifyEnhancedNavigationListners } from './NavigationUtils';
66

77
/*
88
In effect, we have two separate client-side navigation mechanisms:
@@ -74,7 +74,7 @@ function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, rep
7474
history.pushState(null, /* ignored title */ '', absoluteInternalHref);
7575
}
7676

77-
performEnhancedPageLoad(absoluteInternalHref);
77+
performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ false);
7878
}
7979

8080
function onDocumentClick(event: MouseEvent) {
@@ -88,7 +88,7 @@ function onDocumentClick(event: MouseEvent) {
8888

8989
handleClickForNavigationInterception(event, absoluteInternalHref => {
9090
history.pushState(null, /* ignored title */ '', absoluteInternalHref);
91-
performEnhancedPageLoad(absoluteInternalHref);
91+
performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ true);
9292
});
9393
}
9494

@@ -97,7 +97,7 @@ function onPopState(state: PopStateEvent) {
9797
return;
9898
}
9999

100-
performEnhancedPageLoad(location.href);
100+
performEnhancedPageLoad(location.href, /* interceptedLink */ false);
101101
}
102102

103103
function onDocumentSubmit(event: SubmitEvent) {
@@ -139,16 +139,19 @@ function onDocumentSubmit(event: SubmitEvent) {
139139
fetchOptions.body = formData;
140140
}
141141

142-
performEnhancedPageLoad(url.toString(), fetchOptions);
142+
performEnhancedPageLoad(url.toString(), /* interceptedLink */ false, fetchOptions);
143143
}
144144
}
145145

146-
export async function performEnhancedPageLoad(internalDestinationHref: string, fetchOptions?: RequestInit) {
146+
export async function performEnhancedPageLoad(internalDestinationHref: string, interceptedLink: boolean, fetchOptions?: RequestInit) {
147147
performingEnhancedPageLoad = true;
148148

149149
// First, stop any preceding enhanced page load
150150
currentEnhancedNavigationAbortController?.abort();
151151

152+
// Notify any interactive runtimes that an enhanced navigation is starting
153+
notifyEnhancedNavigationListners(internalDestinationHref, interceptedLink);
154+
152155
// Now request the new page via fetch, and a special header that tells the server we want it to inject
153156
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
154157
currentEnhancedNavigationAbortController = new AbortController();

src/Components/Web.JS/src/Services/NavigationManager.ts

+53-25
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@
44
import '@microsoft/dotnet-js-interop';
55
import { resetScrollAfterNextBatch } from '../Rendering/Renderer';
66
import { EventDelegator } from '../Rendering/Events/EventDelegator';
7-
import { handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils';
7+
import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils';
8+
import { WebRendererId } from '../Rendering/WebRendererId';
9+
import { isRendererAttached } from '../Rendering/WebRendererInteropMethods';
810

911
let hasRegisteredNavigationEventListeners = false;
10-
let hasLocationChangingEventListeners = false;
1112
let currentHistoryIndex = 0;
1213
let currentLocationChangingCallId = 0;
1314

14-
// Will be initialized once someone registers
15-
let notifyLocationChangedCallback: ((uri: string, state: string | undefined, intercepted: boolean) => Promise<void>) | null = null;
16-
let notifyLocationChangingCallback: ((callId: number, uri: string, state: string | undefined, intercepted: boolean) => Promise<void>) | null = null;
15+
type NavigationCallbacks = {
16+
rendererId: WebRendererId;
17+
hasLocationChangingEventListeners: boolean;
18+
locationChanged(uri: string, state: string | undefined, intercepted: boolean): Promise<void>;
19+
locationChanging(callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void>;
20+
};
21+
22+
const navigationCallbacks = new Map<WebRendererId, NavigationCallbacks>();
1723

1824
let popStateCallback: ((state: PopStateEvent) => Promise<void> | void) = onBrowserInitiatedPopState;
1925
let resolveCurrentNavigation: ((shouldContinueNavigation: boolean) => void) | null = null;
@@ -32,11 +38,16 @@ export const internalFunctions = {
3238
};
3339

3440
function listenForNavigationEvents(
41+
rendererId: WebRendererId,
3542
locationChangedCallback: (uri: string, state: string | undefined, intercepted: boolean) => Promise<void>,
3643
locationChangingCallback: (callId: number, uri: string, state: string | undefined, intercepted: boolean) => Promise<void>
3744
): void {
38-
notifyLocationChangedCallback = locationChangedCallback;
39-
notifyLocationChangingCallback = locationChangingCallback;
45+
navigationCallbacks.set(rendererId, {
46+
rendererId,
47+
hasLocationChangingEventListeners: false,
48+
locationChanged: locationChangedCallback,
49+
locationChanging: locationChangingCallback,
50+
});
4051

4152
if (hasRegisteredNavigationEventListeners) {
4253
return;
@@ -45,10 +56,18 @@ function listenForNavigationEvents(
4556
hasRegisteredNavigationEventListeners = true;
4657
window.addEventListener('popstate', onPopState);
4758
currentHistoryIndex = history.state?._index ?? 0;
59+
60+
attachEnhancedNavigationListener((internalDestinationHref, interceptedLink) => {
61+
notifyLocationChanged(interceptedLink, internalDestinationHref);
62+
});
4863
}
4964

50-
function setHasLocationChangingListeners(hasListeners: boolean) {
51-
hasLocationChangingEventListeners = hasListeners;
65+
function setHasLocationChangingListeners(rendererId: WebRendererId, hasListeners: boolean) {
66+
const callbacks = navigationCallbacks.get(rendererId);
67+
if (!callbacks) {
68+
throw new Error(`Renderer with ID '${rendererId}' is not listening for navigation events`);
69+
}
70+
callbacks.hasLocationChangingEventListeners = hasListeners;
5271
}
5372

5473
export function scrollToElement(identifier: string): boolean {
@@ -162,8 +181,9 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept
162181
return;
163182
}
164183

165-
if (!skipLocationChangingCallback && hasLocationChangingEventListeners) {
166-
const shouldContinueNavigation = await notifyLocationChanging(absoluteInternalHref, state, interceptedLink);
184+
const callbacks = getInteractiveRouterNavigationCallbacks();
185+
if (!skipLocationChangingCallback && callbacks?.hasLocationChangingEventListeners) {
186+
const shouldContinueNavigation = await notifyLocationChanging(absoluteInternalHref, state, interceptedLink, callbacks);
167187
if (!shouldContinueNavigation) {
168188
return;
169189
}
@@ -216,18 +236,12 @@ function ignorePendingNavigation() {
216236
}
217237
}
218238

219-
function notifyLocationChanging(uri: string, state: string | undefined, intercepted: boolean): Promise<boolean> {
239+
function notifyLocationChanging(uri: string, state: string | undefined, intercepted: boolean, callbacks: NavigationCallbacks): Promise<boolean> {
220240
return new Promise<boolean>(resolve => {
221241
ignorePendingNavigation();
222-
223-
if (!notifyLocationChangingCallback) {
224-
resolve(false);
225-
return;
226-
}
227-
228242
currentLocationChangingCallId++;
229243
resolveCurrentNavigation = resolve;
230-
notifyLocationChangingCallback(currentLocationChangingCallId, uri, state, intercepted);
244+
callbacks.locationChanging(currentLocationChangingCallId, uri, state, intercepted);
231245
});
232246
}
233247

@@ -241,7 +255,8 @@ function endLocationChanging(callId: number, shouldContinueNavigation: boolean)
241255
async function onBrowserInitiatedPopState(state: PopStateEvent) {
242256
ignorePendingNavigation();
243257

244-
if (hasLocationChangingEventListeners) {
258+
const callbacks = getInteractiveRouterNavigationCallbacks();
259+
if (callbacks?.hasLocationChangingEventListeners) {
245260
const index = state.state?._index ?? 0;
246261
const userState = state.state?.userState;
247262
const delta = index - currentHistoryIndex;
@@ -250,7 +265,7 @@ async function onBrowserInitiatedPopState(state: PopStateEvent) {
250265
// Temporarily revert the navigation until we confirm if the navigation should continue.
251266
await navigateHistoryWithoutPopStateCallback(-delta);
252267

253-
const shouldContinueNavigation = await notifyLocationChanging(uri, userState, false);
268+
const shouldContinueNavigation = await notifyLocationChanging(uri, userState, false, callbacks);
254269
if (!shouldContinueNavigation) {
255270
return;
256271
}
@@ -261,10 +276,14 @@ async function onBrowserInitiatedPopState(state: PopStateEvent) {
261276
await notifyLocationChanged(false);
262277
}
263278

264-
async function notifyLocationChanged(interceptedLink: boolean) {
265-
if (notifyLocationChangedCallback) {
266-
await notifyLocationChangedCallback(location.href, history.state?.userState, interceptedLink);
267-
}
279+
async function notifyLocationChanged(interceptedLink: boolean, internalDestinationHref?: string) {
280+
const uri = internalDestinationHref ?? location.href;
281+
282+
await Promise.all(Array.from(navigationCallbacks, async ([rendererId, callbacks]) => {
283+
if (isRendererAttached(rendererId)) {
284+
await callbacks.locationChanged(uri, history.state?.userState, interceptedLink);
285+
}
286+
}));
268287
}
269288

270289
async function onPopState(state: PopStateEvent) {
@@ -275,6 +294,15 @@ async function onPopState(state: PopStateEvent) {
275294
currentHistoryIndex = history.state?._index ?? 0;
276295
}
277296

297+
function getInteractiveRouterNavigationCallbacks(): NavigationCallbacks | undefined {
298+
const interactiveRouterRendererId = getInteractiveRouterRendererId();
299+
if (interactiveRouterRendererId === undefined) {
300+
return undefined;
301+
}
302+
303+
return navigationCallbacks.get(interactiveRouterRendererId);
304+
}
305+
278306
function shouldUseClientSideRouting() {
279307
return hasInteractiveRouter() || !hasProgrammaticEnhancedNavigationHandler();
280308
}

src/Components/Web.JS/src/Services/NavigationUtils.ts

+27-8
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
let hasInteractiveRouterValue = false;
4+
import { WebRendererId } from '../Rendering/WebRendererId';
5+
6+
let interactiveRouterRendererId: WebRendererId | undefined = undefined;
57
let programmaticEnhancedNavigationHandler: typeof performProgrammaticEnhancedNavigation | undefined;
8+
let enhancedNavigationListener: typeof notifyEnhancedNavigationListners | undefined;
69

710
/**
811
* Checks if a click event corresponds to an <a> tag referencing a URL within the base href, and that interception
@@ -44,6 +47,14 @@ export function isWithinBaseUriSpace(href: string) {
4447
&& (nextChar === '' || nextChar === '/' || nextChar === '?' || nextChar === '#');
4548
}
4649

50+
export function attachEnhancedNavigationListener(listener: typeof enhancedNavigationListener) {
51+
enhancedNavigationListener = listener;
52+
}
53+
54+
export function notifyEnhancedNavigationListners(internalDestinationHref: string, interceptedLink: boolean) {
55+
enhancedNavigationListener?.(internalDestinationHref, interceptedLink);
56+
}
57+
4758
export function hasProgrammaticEnhancedNavigationHandler(): boolean {
4859
return programmaticEnhancedNavigationHandler !== undefined;
4960
}
@@ -107,16 +118,24 @@ function findAnchorTarget(event: MouseEvent): HTMLAnchorElement | null {
107118

108119
function findClosestAnchorAncestorLegacy(element: Element | null, tagName: string) {
109120
return !element
110-
? null
111-
: element.tagName === tagName
112-
? element
113-
: findClosestAnchorAncestorLegacy(element.parentElement, tagName);
121+
? null
122+
: element.tagName === tagName
123+
? element
124+
: findClosestAnchorAncestorLegacy(element.parentElement, tagName);
114125
}
115126

116127
export function hasInteractiveRouter(): boolean {
117-
return hasInteractiveRouterValue;
128+
return interactiveRouterRendererId !== undefined;
118129
}
119130

120-
export function setHasInteractiveRouter() {
121-
hasInteractiveRouterValue = true;
131+
export function getInteractiveRouterRendererId() : WebRendererId | undefined {
132+
return interactiveRouterRendererId;
133+
}
134+
135+
export function setHasInteractiveRouter(rendererId: WebRendererId) {
136+
if (interactiveRouterRendererId !== undefined && interactiveRouterRendererId !== rendererId) {
137+
throw new Error('Only one interactive runtime may enable navigation interception at a time.');
138+
}
139+
140+
interactiveRouterRendererId = rendererId;
122141
}

src/Components/WebAssembly/WebAssembly/src/Services/IInternalJSImportMethods.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ internal interface IInternalJSImportMethods
99

1010
string GetApplicationEnvironment();
1111

12-
void NavigationManager_EnableNavigationInterception();
12+
void NavigationManager_EnableNavigationInterception(int rendererId);
1313

1414
void NavigationManager_ScrollToElement(string id);
1515

1616
string NavigationManager_GetLocationHref();
1717

1818
string NavigationManager_GetBaseUri();
1919

20-
void NavigationManager_SetHasLocationChangingListeners(bool value);
20+
void NavigationManager_SetHasLocationChangingListeners(int rendererId, bool value);
2121

2222
int RegisteredComponents_GetRegisteredComponentsCount();
2323

0 commit comments

Comments
 (0)