Skip to content

Commit 468ff78

Browse files
[Blazor] APIs to preserve content between enhanced navigations (#50437)
# APIs to preserve content between enhanced navigations Adds APIs to enable the preservation of DOM content between enhanced navigations. ## Description This PR introduces two new ways to preserve content between enhanced navigations: 1. The `data-permanent` HTML attribute * Elements that contain this attribute will have their content preserved between enhanced page updates. The `id` attribute on an element marked as `data-permanent` will be used to match up data permanent elements when performing DOM synchronization. * Note that the data permanent element itself is still subject to removal via SSR update if the updated region includes the existing `data-permanent` element. Therefore, the `data-permanent` element itself should **not** be added dynamically via JS, (but its _content_ can be, which is the primary use case). 2. `Blazor.registerEnhancedPageUpdateCallback(callback)` * This API can be used in cases where the `data-permanent` attribute is not sufficient. It lets you get notified when an enhanced page update occurs so changes to the DOM can be made in response. * Callbacks will be invoked any time the document changes due to an enhanced update, including streaming rendering updates and enhanced form posts. It does not _only_ get involved when a navigation occurs. * Callbacks do not get invoked automatically upon registration or after the initial page load. Fixes #49613
1 parent 2ac6452 commit 468ff78

File tree

15 files changed

+320
-19
lines changed

15 files changed

+320
-19
lines changed

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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { setCircuitOptions, startServer } from './Boot.Server.Common';
88
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
99
import { DotNet } from '@microsoft/dotnet-js-interop';
1010
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
11+
import { JSEventRegistry } from './Services/JSEventRegistry';
1112

1213
let started = false;
1314

@@ -19,6 +20,7 @@ function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {
1920

2021
setCircuitOptions(userOptions);
2122

23+
JSEventRegistry.create(Blazor);
2224
const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
2325
const components = new InitialRootComponentsList(serverComponents);
2426
return startServer(components);

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import { shouldAutoStart } from './BootCommon';
1515
import { Blazor } from './GlobalExports';
1616
import { WebStartOptions } from './Platform/WebStartOptions';
1717
import { attachStreamingRenderingListener } from './Rendering/StreamingRendering';
18-
import { attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
18+
import { NavigationEnhancementCallbacks, attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
1919
import { WebRootComponentManager } from './Services/WebRootComponentManager';
20-
import { attachComponentDescriptorHandler, registerAllComponentDescriptors } from './Rendering/DomMerging/DomSync';
2120
import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils';
21+
import { attachComponentDescriptorHandler, registerAllComponentDescriptors } from './Rendering/DomMerging/DomSync';
22+
import { JSEventRegistry } from './Services/JSEventRegistry';
2223

2324
let started = false;
2425
let rootComponentManager: WebRootComponentManager;
@@ -44,12 +45,23 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
4445
setWebAssemblyOptions(options?.webAssembly);
4546

4647
rootComponentManager = new WebRootComponentManager(options?.ssr?.circuitInactivityTimeoutMs ?? 2000);
48+
const jsEventRegistry = JSEventRegistry.create(Blazor);
49+
50+
const navigationEnhancementCallbacks: NavigationEnhancementCallbacks = {
51+
documentUpdated: () => {
52+
rootComponentManager.onDocumentUpdated();
53+
jsEventRegistry.dispatchEvent('enhancedload', {});
54+
},
55+
enhancedNavigationCompleted() {
56+
rootComponentManager.onEnhancedNavigationCompleted();
57+
},
58+
};
4759

4860
attachComponentDescriptorHandler(rootComponentManager);
49-
attachStreamingRenderingListener(options?.ssr, rootComponentManager);
61+
attachStreamingRenderingListener(options?.ssr, navigationEnhancementCallbacks);
5062

5163
if (!options?.ssr?.disableDomPreservation) {
52-
attachProgressivelyEnhancedNavigationListener(rootComponentManager);
64+
attachProgressivelyEnhancedNavigationListener(navigationEnhancementCallbacks);
5365
}
5466

5567
// Wait until the initial page response completes before activating interactive components.
@@ -66,7 +78,7 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
6678

6779
function onInitialDomContentLoaded() {
6880
registerAllComponentDescriptors(document);
69-
rootComponentManager.documentUpdated();
81+
rootComponentManager.onDocumentUpdated();
7082
}
7183

7284
Blazor.start = boot;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { setWebAssemblyOptions, startWebAssembly } from './Boot.WebAssembly.Comm
1010
import { WebAssemblyComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
1111
import { DotNet } from '@microsoft/dotnet-js-interop';
1212
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
13+
import { JSEventRegistry } from './Services/JSEventRegistry';
1314

1415
let started = false;
1516

@@ -21,6 +22,7 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
2122

2223
setWebAssemblyOptions(options);
2324

25+
JSEventRegistry.create(Blazor);
2426
const webAssemblyComponents = discoverComponents(document, 'webassembly') as WebAssemblyComponentDescriptor[];
2527
const components = new InitialRootComponentsList(webAssemblyComponents);
2628
await startWebAssembly(components);

src/Components/Web.JS/src/GlobalExports.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { RootComponentsFunctions } from './Rendering/JSRootComponents';
1818
import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods';
1919
import { WebStartOptions } from './Platform/WebStartOptions';
2020
import { RuntimeAPI } from 'dotnet';
21+
import { JSEventRegistry } from './Services/JSEventRegistry';
2122

2223
// TODO: It's kind of hard to tell which .NET platform(s) some of these APIs are relevant to.
2324
// It's important to know this information when dealing with the possibility of mulitple .NET platforms being available.
@@ -29,10 +30,12 @@ import { RuntimeAPI } from 'dotnet';
2930
// * Blazor._internal.{foo}: internal, platform-agnostic Blazor APIs
3031
// * Blazor.platform.{somePlatformName}.{foo}: public, platform-specific Blazor APIs (would be empty at first, so no initial breaking changes)
3132
// * Blazor.platform.{somePlatformName}.{_internal}.{foo}: internal, platform-specific Blazor APIs
32-
interface IBlazor {
33+
export interface IBlazor {
3334
navigateTo: (uri: string, options: NavigationOptions) => void;
3435
registerCustomEventType: (eventName: string, options: EventTypeOptions) => void;
3536

37+
addEventListener?: typeof JSEventRegistry.prototype.addEventListener;
38+
removeEventListener?: typeof JSEventRegistry.prototype.removeEventListener;
3639
disconnect?: () => void;
3740
reconnect?: (existingConnection?: HubConnection) => Promise<boolean>;
3841
defaultReconnectionHandler?: DefaultReconnectionHandler;
@@ -104,7 +107,7 @@ export const Blazor: IBlazor = {
104107
InputFile,
105108
NavigationLock,
106109
getJSDataStreamChunk: getNextChunk,
107-
attachWebRendererInterop
110+
attachWebRendererInterop,
108111
},
109112
};
110113

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
const dataPermanentAttributeName = 'data-permanent';
5+
6+
export function isDataPermanentElement(elem: Element): boolean {
7+
return elem.hasAttribute(dataPermanentAttributeName);
8+
}
9+
10+
export function cannotMergeDueToDataPermanentAttributes(elementA: Element, elementB: Element) {
11+
const dataPermanentAttributeValueA = elementA.getAttribute(dataPermanentAttributeName);
12+
const dataPermanentAttributeValueB = elementB.getAttribute(dataPermanentAttributeName);
13+
14+
return dataPermanentAttributeValueA !== dataPermanentAttributeValueB;
15+
}

src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isInteractiveRootComponentElement } from '../BrowserRenderer';
66
import { applyAnyDeferredValue } from '../DomSpecialPropertyUtil';
77
import { LogicalElement, getLogicalChildrenArray, getLogicalNextSibling, getLogicalParent, getLogicalRootDescriptor, insertLogicalChild, insertLogicalChildBefore, isLogicalElement, toLogicalElement, toLogicalRootCommentElement } from '../LogicalElements';
88
import { synchronizeAttributes } from './AttributeSync';
9+
import { cannotMergeDueToDataPermanentAttributes, isDataPermanentElement } from './DataPermanentElementSync';
910
import { UpdateCost, ItemList, Operation, computeEditScript } from './EditScript';
1011

1112
let descriptorHandler: DescriptorHandler | null = null;
@@ -184,7 +185,12 @@ function treatAsMatch(destination: Node, source: Node) {
184185
const editableElementValue = getEditableElementValue(source as Element);
185186
synchronizeAttributes(destination as Element, source as Element);
186187
applyAnyDeferredValue(destination as Element);
187-
synchronizeDomContentCore(destination as Element, source as Element);
188+
189+
if (isDataPermanentElement(destination as Element)) {
190+
// The destination element's content should be retained, so we avoid recursing into it.
191+
} else {
192+
synchronizeDomContentCore(destination as Element, source as Element);
193+
}
188194

189195
// This is a much simpler alternative to the deferred-value-assignment logic we use in interactive rendering.
190196
// Because this sync algorithm goes depth-first, we know all the attributes and descendants are fully in sync
@@ -288,7 +294,18 @@ function domNodeComparer(a: Node, b: Node): UpdateCost {
288294
// to return UpdateCost.Infinite if either has a key but they don't match. This will prevent unwanted retention.
289295
// For the converse (forcing retention, even if that means reordering), we could post-process the list of
290296
// inserts/deletes to find matches based on key to treat those pairs as 'move' operations.
291-
return (a as Element).tagName === (b as Element).tagName ? UpdateCost.None : UpdateCost.Infinite;
297+
if ((a as Element).tagName !== (b as Element).tagName) {
298+
return UpdateCost.Infinite;
299+
}
300+
301+
// The two elements must have matching 'data-permanent' attribute values for them to be merged. If they don't match, either:
302+
// [1] We're comparing a data-permanent element to a non-data-permanent one.
303+
// [2] We're comparing elements that represent two different data-permanent containers.
304+
if (cannotMergeDueToDataPermanentAttributes(a as Element, b as Element)) {
305+
return UpdateCost.Infinite;
306+
}
307+
308+
return UpdateCost.None;
292309
case Node.DOCUMENT_TYPE_NODE:
293310
// It's invalid to insert or delete doctype, and we have no use case for doing that. So just skip such
294311
// nodes by saying they are always unchanged.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
import { IBlazor } from '../GlobalExports';
5+
6+
// The base Blazor event type.
7+
// Properties listed here get assigned by the event registry in 'dispatchEvent'.
8+
interface BlazorEvent {
9+
type: keyof BlazorEventMap;
10+
}
11+
12+
// Maps Blazor event names to the argument type passed to registered listeners.
13+
export interface BlazorEventMap {
14+
'enhancedload': BlazorEvent;
15+
}
16+
17+
export class JSEventRegistry {
18+
private readonly _eventListeners = new Map<string, Set<(ev: any) => void>>();
19+
20+
static create(blazor: IBlazor): JSEventRegistry {
21+
const result = new JSEventRegistry();
22+
blazor.addEventListener = result.addEventListener.bind(result);
23+
blazor.removeEventListener = result.removeEventListener.bind(result);
24+
return result;
25+
}
26+
27+
public addEventListener<K extends keyof BlazorEventMap>(type: K, listener: (ev: BlazorEventMap[K]) => void): void {
28+
let listenersForEventType = this._eventListeners.get(type);
29+
if (!listenersForEventType) {
30+
listenersForEventType = new Set();
31+
this._eventListeners.set(type, listenersForEventType);
32+
}
33+
34+
listenersForEventType.add(listener);
35+
}
36+
37+
public removeEventListener<K extends keyof BlazorEventMap>(type: K, listener: (ev: BlazorEventMap[K]) => void): void {
38+
this._eventListeners.get(type)?.delete(listener);
39+
}
40+
41+
public dispatchEvent<K extends keyof BlazorEventMap>(type: K, ev: Omit<BlazorEventMap[K], keyof BlazorEvent>): void {
42+
const listenersForEventType = this._eventListeners.get(type);
43+
if (!listenersForEventType) {
44+
return;
45+
}
46+
47+
const event: BlazorEventMap[K] = {
48+
...ev,
49+
type,
50+
};
51+
52+
for (const listener of listenersForEventType) {
53+
listener(event);
54+
}
55+
}
56+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ let performingEnhancedPageLoad: boolean;
3737

3838
export interface NavigationEnhancementCallbacks {
3939
documentUpdated: () => void;
40+
enhancedNavigationCompleted: () => void;
4041
}
4142

4243
export function isPageLoading() {
@@ -250,7 +251,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, f
250251
}
251252

252253
performingEnhancedPageLoad = false;
253-
navigationEnhancementCallbacks.documentUpdated();
254+
navigationEnhancementCallbacks.enhancedNavigationCompleted();
254255
}
255256
}
256257

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
import { ComponentDescriptor, ComponentMarker, descriptorToMarker } from './ComponentDescriptorDiscovery';
55
import { isRendererAttached, registerRendererAttachedListener, updateRootComponents } from '../Rendering/WebRendererInteropMethods';
66
import { WebRendererId } from '../Rendering/WebRendererId';
7-
import { NavigationEnhancementCallbacks, isPageLoading } from './NavigationEnhancement';
87
import { DescriptorHandler } from '../Rendering/DomMerging/DomSync';
98
import { disposeCircuit, hasStartedServer, isCircuitAvailable, startCircuit, startServer } from '../Boot.Server.Common';
109
import { hasLoadedWebAssemblyPlatform, hasStartedLoadingWebAssemblyPlatform, hasStartedWebAssembly, loadWebAssemblyPlatformIfNotStarted, startWebAssembly, waitForBootConfigLoaded } from '../Boot.WebAssembly.Common';
1110
import { MonoConfig } from 'dotnet';
1211
import { RootComponentManager } from './RootComponentManager';
1312
import { Blazor } from '../GlobalExports';
1413
import { getRendererer } from '../Rendering/Renderer';
14+
import { isPageLoading } from './NavigationEnhancement';
1515

1616
type RootComponentOperation = RootComponentAddOperation | RootComponentUpdateOperation | RootComponentRemoveOperation;
1717

@@ -39,7 +39,7 @@ type RootComponentInfo = {
3939
interactiveComponentId?: number;
4040
}
4141

42-
export class WebRootComponentManager implements DescriptorHandler, NavigationEnhancementCallbacks, RootComponentManager<never> {
42+
export class WebRootComponentManager implements DescriptorHandler, RootComponentManager<never> {
4343
private readonly _rootComponents = new Set<RootComponentInfo>();
4444

4545
private readonly _descriptors = new Set<ComponentDescriptor>();
@@ -65,18 +65,24 @@ export class WebRootComponentManager implements DescriptorHandler, NavigationEnh
6565
});
6666
}
6767

68-
// Implements NavigationEnhancementCallbacks.
69-
public documentUpdated() {
70-
this.rootComponentsMayRequireRefresh();
71-
}
72-
7368
// Implements RootComponentManager.
7469
public onAfterRenderBatch(browserRendererId: number): void {
7570
if (browserRendererId === WebRendererId.Server) {
7671
this.circuitMayHaveNoRootComponents();
7772
}
7873
}
7974

75+
public onDocumentUpdated() {
76+
// Root components may have been added, updated, or removed.
77+
this.rootComponentsMayRequireRefresh();
78+
}
79+
80+
public onEnhancedNavigationCompleted() {
81+
// Root components may now be ready for activation if they had been previously
82+
// skipped for activation due to an enhanced navigation being underway.
83+
this.rootComponentsMayRequireRefresh();
84+
}
85+
8086
public registerComponent(descriptor: ComponentDescriptor) {
8187
if (this._descriptors.has(descriptor)) {
8288
return;

src/Components/Web.JS/test/DomSync.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,63 @@ describe('DomSync', () => {
492492
expect(newDocTypeNode).toBe(origDocTypeNode);
493493
expect(destination.body.textContent).toBe('Goodbye');
494494
});
495+
496+
test('should preserve content in elements marked as data permanent', () => {
497+
// Arrange
498+
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent>preserved</div><div>also not preserved</div>`);
499+
const newContent = makeNewContent(`<div></div><div data-permanent>other content</div><div></div>`);
500+
const oldNodes = toNodeArray(destination);
501+
502+
// Act
503+
synchronizeDomContent(destination, newContent);
504+
const newNodes = toNodeArray(destination);
505+
506+
// Assert
507+
expect(oldNodes[0]).toBe(newNodes[0]);
508+
expect(oldNodes[1]).toBe(newNodes[1]);
509+
expect(newNodes[0].textContent).toBe('');
510+
expect(newNodes[1].textContent).toBe('preserved');
511+
});
512+
513+
test('should preserve content in elements marked as data permanent by matching attribute value', () => {
514+
// Arrange
515+
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent="first">first preserved</div>`);
516+
const newContent1 = makeNewContent(`<div>not preserved</div><div data-permanent="second">second preserved</div><div data-permanent="first">other content</div>`);
517+
const newContent2 = makeNewContent(`<div>not preserved</div><div data-permanent="second">other content</div><div id="foo"></div><div data-permanent="first">other content</div>`);
518+
const nodes1 = toNodeArray(destination);
519+
520+
// Act/assert 1: The original data permanent content is preserved
521+
synchronizeDomContent(destination, newContent1);
522+
const nodes2 = toNodeArray(destination);
523+
expect(nodes1[1]).toBe(nodes2[2]);
524+
expect(nodes2[1].textContent).toBe('second preserved');
525+
expect(nodes2[2].textContent).toBe('first preserved');
526+
527+
// Act/assert 2: The new data permanent content is preserved
528+
synchronizeDomContent(destination, newContent2);
529+
const nodes3 = toNodeArray(destination);
530+
expect(nodes2[1]).toBe(nodes3[1]);
531+
expect(nodes2[2]).toBe(nodes3[3]);
532+
expect(nodes3[1].textContent).toBe('second preserved');
533+
expect(nodes3[3].textContent).toBe('first preserved');
534+
});
535+
536+
test('should not preserve content in elements marked as data permanent if attribute value does not match', () => {
537+
// Arrange
538+
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent="first">preserved</div><div>also not preserved</div>`);
539+
const newContent = makeNewContent(`<div></div><div data-permanent="second">new content</div><div></div>`);
540+
const oldNodes = toNodeArray(destination);
541+
542+
// Act
543+
synchronizeDomContent(destination, newContent);
544+
const newNodes = toNodeArray(destination);
545+
546+
// Assert
547+
expect(oldNodes[0]).toBe(newNodes[0]);
548+
expect(oldNodes[1]).not.toBe(newNodes[1]);
549+
expect(newNodes[0].textContent).toBe('');
550+
expect(newNodes[1].textContent).toBe('new content');
551+
});
495552
});
496553

497554
test('should remove value if neither source nor destination has one', () => {

0 commit comments

Comments
 (0)