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
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: 2 additions & 0 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { setCircuitOptions, startServer } from './Boot.Server.Common';
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
import { DotNet } from '@microsoft/dotnet-js-interop';
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
import { JSEventRegistry } from './Services/JSEventRegistry';

let started = false;

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

setCircuitOptions(userOptions);

JSEventRegistry.create(Blazor);
const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
const components = new InitialRootComponentsList(serverComponents);
return startServer(components);
Expand Down
22 changes: 17 additions & 5 deletions src/Components/Web.JS/src/Boot.Web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import { shouldAutoStart } from './BootCommon';
import { Blazor } from './GlobalExports';
import { WebStartOptions } from './Platform/WebStartOptions';
import { attachStreamingRenderingListener } from './Rendering/StreamingRendering';
import { attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
import { NavigationEnhancementCallbacks, attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
import { WebRootComponentManager } from './Services/WebRootComponentManager';
import { attachComponentDescriptorHandler, registerAllComponentDescriptors } from './Rendering/DomMerging/DomSync';
import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils';
import { attachComponentDescriptorHandler, registerAllComponentDescriptors } from './Rendering/DomMerging/DomSync';
import { JSEventRegistry } from './Services/JSEventRegistry';

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

rootComponentManager = new WebRootComponentManager(options?.ssr?.circuitInactivityTimeoutMs ?? 2000);
const jsEventRegistry = JSEventRegistry.create(Blazor);

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

attachComponentDescriptorHandler(rootComponentManager);
attachStreamingRenderingListener(options?.ssr, rootComponentManager);
attachStreamingRenderingListener(options?.ssr, navigationEnhancementCallbacks);

if (!options?.ssr?.disableDomPreservation) {
attachProgressivelyEnhancedNavigationListener(rootComponentManager);
attachProgressivelyEnhancedNavigationListener(navigationEnhancementCallbacks);
}

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

function onInitialDomContentLoaded() {
registerAllComponentDescriptors(document);
rootComponentManager.documentUpdated();
rootComponentManager.onDocumentUpdated();
}

Blazor.start = boot;
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Web.JS/src/Boot.WebAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { setWebAssemblyOptions, startWebAssembly } from './Boot.WebAssembly.Comm
import { WebAssemblyComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
import { DotNet } from '@microsoft/dotnet-js-interop';
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
import { JSEventRegistry } from './Services/JSEventRegistry';

let started = false;

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

setWebAssemblyOptions(options);

JSEventRegistry.create(Blazor);
const webAssemblyComponents = discoverComponents(document, 'webassembly') as WebAssemblyComponentDescriptor[];
const components = new InitialRootComponentsList(webAssemblyComponents);
await startWebAssembly(components);
Expand Down
7 changes: 5 additions & 2 deletions src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { RootComponentsFunctions } from './Rendering/JSRootComponents';
import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods';
import { WebStartOptions } from './Platform/WebStartOptions';
import { RuntimeAPI } from 'dotnet';
import { JSEventRegistry } from './Services/JSEventRegistry';

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

addEventListener?: typeof JSEventRegistry.prototype.addEventListener;
removeEventListener?: typeof JSEventRegistry.prototype.removeEventListener;
disconnect?: () => void;
reconnect?: (existingConnection?: HubConnection) => Promise<boolean>;
defaultReconnectionHandler?: DefaultReconnectionHandler;
Expand Down Expand Up @@ -104,7 +107,7 @@ export const Blazor: IBlazor = {
InputFile,
NavigationLock,
getJSDataStreamChunk: getNextChunk,
attachWebRendererInterop
attachWebRendererInterop,
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

const dataPermanentAttributeName = 'data-permanent';

export function isDataPermanentElement(elem: Element): boolean {
return elem.hasAttribute(dataPermanentAttributeName);
}

export function cannotMergeDueToDataPermanentAttributes(elementA: Element, elementB: Element) {
const dataPermanentAttributeValueA = elementA.getAttribute(dataPermanentAttributeName);
const dataPermanentAttributeValueB = elementB.getAttribute(dataPermanentAttributeName);

return dataPermanentAttributeValueA !== dataPermanentAttributeValueB;
}
21 changes: 19 additions & 2 deletions src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isInteractiveRootComponentElement } from '../BrowserRenderer';
import { applyAnyDeferredValue } from '../DomSpecialPropertyUtil';
import { LogicalElement, getLogicalChildrenArray, getLogicalNextSibling, getLogicalParent, getLogicalRootDescriptor, insertLogicalChild, insertLogicalChildBefore, isLogicalElement, toLogicalElement, toLogicalRootCommentElement } from '../LogicalElements';
import { synchronizeAttributes } from './AttributeSync';
import { cannotMergeDueToDataPermanentAttributes, isDataPermanentElement } from './DataPermanentElementSync';
import { UpdateCost, ItemList, Operation, computeEditScript } from './EditScript';

let descriptorHandler: DescriptorHandler | null = null;
Expand Down Expand Up @@ -184,7 +185,12 @@ function treatAsMatch(destination: Node, source: Node) {
const editableElementValue = getEditableElementValue(source as Element);
synchronizeAttributes(destination as Element, source as Element);
applyAnyDeferredValue(destination as Element);
synchronizeDomContentCore(destination as Element, source as Element);

if (isDataPermanentElement(destination as Element)) {
// The destination element's content should be retained, so we avoid recursing into it.
} else {
synchronizeDomContentCore(destination as Element, source as Element);
}

// This is a much simpler alternative to the deferred-value-assignment logic we use in interactive rendering.
// Because this sync algorithm goes depth-first, we know all the attributes and descendants are fully in sync
Expand Down Expand Up @@ -288,7 +294,18 @@ function domNodeComparer(a: Node, b: Node): UpdateCost {
// to return UpdateCost.Infinite if either has a key but they don't match. This will prevent unwanted retention.
// For the converse (forcing retention, even if that means reordering), we could post-process the list of
// inserts/deletes to find matches based on key to treat those pairs as 'move' operations.
return (a as Element).tagName === (b as Element).tagName ? UpdateCost.None : UpdateCost.Infinite;
if ((a as Element).tagName !== (b as Element).tagName) {
return UpdateCost.Infinite;
}

// The two elements must have matching 'data-permanent' attribute values for them to be merged. If they don't match, either:
// [1] We're comparing a data-permanent element to a non-data-permanent one.
// [2] We're comparing elements that represent two different data-permanent containers.
if (cannotMergeDueToDataPermanentAttributes(a as Element, b as Element)) {
return UpdateCost.Infinite;
}

return UpdateCost.None;
case Node.DOCUMENT_TYPE_NODE:
// It's invalid to insert or delete doctype, and we have no use case for doing that. So just skip such
// nodes by saying they are always unchanged.
Expand Down
56 changes: 56 additions & 0 deletions src/Components/Web.JS/src/Services/JSEventRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { IBlazor } from '../GlobalExports';

// The base Blazor event type.
// Properties listed here get assigned by the event registry in 'dispatchEvent'.
interface BlazorEvent {
type: keyof BlazorEventMap;
}

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

export class JSEventRegistry {
private readonly _eventListeners = new Map<string, Set<(ev: any) => void>>();

static create(blazor: IBlazor): JSEventRegistry {
const result = new JSEventRegistry();
blazor.addEventListener = result.addEventListener.bind(result);
blazor.removeEventListener = result.removeEventListener.bind(result);
return result;
}

public addEventListener<K extends keyof BlazorEventMap>(type: K, listener: (ev: BlazorEventMap[K]) => void): void {
let listenersForEventType = this._eventListeners.get(type);
if (!listenersForEventType) {
listenersForEventType = new Set();
this._eventListeners.set(type, listenersForEventType);
}

listenersForEventType.add(listener);
}

public removeEventListener<K extends keyof BlazorEventMap>(type: K, listener: (ev: BlazorEventMap[K]) => void): void {
this._eventListeners.get(type)?.delete(listener);
}

public dispatchEvent<K extends keyof BlazorEventMap>(type: K, ev: Omit<BlazorEventMap[K], keyof BlazorEvent>): void {
const listenersForEventType = this._eventListeners.get(type);
if (!listenersForEventType) {
return;
}

const event: BlazorEventMap[K] = {
...ev,
type,
};

for (const listener of listenersForEventType) {
listener(event);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ let performingEnhancedPageLoad: boolean;

export interface NavigationEnhancementCallbacks {
documentUpdated: () => void;
enhancedNavigationCompleted: () => void;
}

export function isPageLoading() {
Expand Down Expand Up @@ -250,7 +251,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, f
}

performingEnhancedPageLoad = false;
navigationEnhancementCallbacks.documentUpdated();
navigationEnhancementCallbacks.enhancedNavigationCompleted();
}
}

Expand Down
20 changes: 13 additions & 7 deletions src/Components/Web.JS/src/Services/WebRootComponentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
import { ComponentDescriptor, ComponentMarker, descriptorToMarker } from './ComponentDescriptorDiscovery';
import { isRendererAttached, registerRendererAttachedListener, updateRootComponents } from '../Rendering/WebRendererInteropMethods';
import { WebRendererId } from '../Rendering/WebRendererId';
import { NavigationEnhancementCallbacks, isPageLoading } from './NavigationEnhancement';
import { DescriptorHandler } from '../Rendering/DomMerging/DomSync';
import { disposeCircuit, hasStartedServer, isCircuitAvailable, startCircuit, startServer } from '../Boot.Server.Common';
import { hasLoadedWebAssemblyPlatform, hasStartedLoadingWebAssemblyPlatform, hasStartedWebAssembly, loadWebAssemblyPlatformIfNotStarted, startWebAssembly, waitForBootConfigLoaded } from '../Boot.WebAssembly.Common';
import { MonoConfig } from 'dotnet';
import { RootComponentManager } from './RootComponentManager';
import { Blazor } from '../GlobalExports';
import { getRendererer } from '../Rendering/Renderer';
import { isPageLoading } from './NavigationEnhancement';

type RootComponentOperation = RootComponentAddOperation | RootComponentUpdateOperation | RootComponentRemoveOperation;

Expand Down Expand Up @@ -39,7 +39,7 @@ type RootComponentInfo = {
interactiveComponentId?: number;
}

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

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

// Implements NavigationEnhancementCallbacks.
public documentUpdated() {
this.rootComponentsMayRequireRefresh();
}

// Implements RootComponentManager.
public onAfterRenderBatch(browserRendererId: number): void {
if (browserRendererId === WebRendererId.Server) {
this.circuitMayHaveNoRootComponents();
}
}

public onDocumentUpdated() {
// Root components may have been added, updated, or removed.
this.rootComponentsMayRequireRefresh();
}

public onEnhancedNavigationCompleted() {
// Root components may now be ready for activation if they had been previously
// skipped for activation due to an enhanced navigation being underway.
this.rootComponentsMayRequireRefresh();
}

public registerComponent(descriptor: ComponentDescriptor) {
if (this._descriptors.has(descriptor)) {
return;
Expand Down
57 changes: 57 additions & 0 deletions src/Components/Web.JS/test/DomSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,63 @@ describe('DomSync', () => {
expect(newDocTypeNode).toBe(origDocTypeNode);
expect(destination.body.textContent).toBe('Goodbye');
});

test('should preserve content in elements marked as data permanent', () => {
// Arrange
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent>preserved</div><div>also not preserved</div>`);
const newContent = makeNewContent(`<div></div><div data-permanent>other content</div><div></div>`);
const oldNodes = toNodeArray(destination);

// Act
synchronizeDomContent(destination, newContent);
const newNodes = toNodeArray(destination);

// Assert
expect(oldNodes[0]).toBe(newNodes[0]);
expect(oldNodes[1]).toBe(newNodes[1]);
expect(newNodes[0].textContent).toBe('');
expect(newNodes[1].textContent).toBe('preserved');
});

test('should preserve content in elements marked as data permanent by matching attribute value', () => {
// Arrange
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent="first">first preserved</div>`);
const newContent1 = makeNewContent(`<div>not preserved</div><div data-permanent="second">second preserved</div><div data-permanent="first">other content</div>`);
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>`);
const nodes1 = toNodeArray(destination);

// Act/assert 1: The original data permanent content is preserved
synchronizeDomContent(destination, newContent1);
const nodes2 = toNodeArray(destination);
expect(nodes1[1]).toBe(nodes2[2]);
expect(nodes2[1].textContent).toBe('second preserved');
expect(nodes2[2].textContent).toBe('first preserved');

// Act/assert 2: The new data permanent content is preserved
synchronizeDomContent(destination, newContent2);
const nodes3 = toNodeArray(destination);
expect(nodes2[1]).toBe(nodes3[1]);
expect(nodes2[2]).toBe(nodes3[3]);
expect(nodes3[1].textContent).toBe('second preserved');
expect(nodes3[3].textContent).toBe('first preserved');
});

test('should not preserve content in elements marked as data permanent if attribute value does not match', () => {
// Arrange
const destination = makeExistingContent(`<div>not preserved</div><div data-permanent="first">preserved</div><div>also not preserved</div>`);
const newContent = makeNewContent(`<div></div><div data-permanent="second">new content</div><div></div>`);
const oldNodes = toNodeArray(destination);

// Act
synchronizeDomContent(destination, newContent);
const newNodes = toNodeArray(destination);

// Assert
expect(oldNodes[0]).toBe(newNodes[0]);
expect(oldNodes[1]).not.toBe(newNodes[1]);
expect(newNodes[0].textContent).toBe('');
expect(newNodes[1].textContent).toBe('new content');
});
});

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