For two-way bindings, enforce consistency between .NET model and DOM by patching old tree. Fixes #8204 (#11438)
This commit is contained in:
parent
151ae52661
commit
f162ba1961
|
|
@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
public WebAssemblyRenderer(System.IServiceProvider serviceProvider) : base (default(System.IServiceProvider)) { }
|
public WebAssemblyRenderer(System.IServiceProvider serviceProvider) : base (default(System.IServiceProvider)) { }
|
||||||
public System.Threading.Tasks.Task AddComponentAsync(System.Type componentType, string domElementSelector) { throw null; }
|
public System.Threading.Tasks.Task AddComponentAsync(System.Type componentType, string domElementSelector) { throw null; }
|
||||||
public System.Threading.Tasks.Task AddComponentAsync<TComponent>(string domElementSelector) where TComponent : Microsoft.AspNetCore.Components.IComponent { throw null; }
|
public System.Threading.Tasks.Task AddComponentAsync<TComponent>(string domElementSelector) where TComponent : Microsoft.AspNetCore.Components.IComponent { throw null; }
|
||||||
public override System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
|
public override System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.Rendering.EventFieldInfo eventFieldInfo, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
|
||||||
protected override void Dispose(bool disposing) { }
|
protected override void Dispose(bool disposing) { }
|
||||||
protected override void HandleException(System.Exception exception) { }
|
protected override void HandleException(System.Exception exception) { }
|
||||||
protected override System.Threading.Tasks.Task UpdateDisplayAsync(in Microsoft.AspNetCore.Components.Rendering.RenderBatch batch) { throw null; }
|
protected override System.Threading.Tasks.Task UpdateDisplayAsync(in Microsoft.AspNetCore.Components.Rendering.RenderBatch batch) { throw null; }
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task DispatchEventAsync(int eventHandlerId, UIEventArgs eventArgs)
|
public override Task DispatchEventAsync(int eventHandlerId, EventFieldInfo eventFieldInfo, UIEventArgs eventArgs)
|
||||||
{
|
{
|
||||||
// Be sure we only run one event handler at once. Although they couldn't run
|
// Be sure we only run one event handler at once. Although they couldn't run
|
||||||
// simultaneously anyway (there's only one thread), they could run nested on
|
// simultaneously anyway (there's only one thread), they could run nested on
|
||||||
|
|
@ -135,7 +135,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
|
|
||||||
if (isDispatchingEvent)
|
if (isDispatchingEvent)
|
||||||
{
|
{
|
||||||
var info = new IncomingEventInfo(eventHandlerId, eventArgs);
|
var info = new IncomingEventInfo(eventHandlerId, eventFieldInfo, eventArgs);
|
||||||
deferredIncomingEvents.Enqueue(info);
|
deferredIncomingEvents.Enqueue(info);
|
||||||
return info.TaskCompletionSource.Task;
|
return info.TaskCompletionSource.Task;
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +144,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
isDispatchingEvent = true;
|
isDispatchingEvent = true;
|
||||||
return base.DispatchEventAsync(eventHandlerId, eventArgs);
|
return base.DispatchEventAsync(eventHandlerId, eventFieldInfo, eventArgs);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -168,7 +168,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DispatchEventAsync(info.EventHandlerId, info.EventArgs);
|
await DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs);
|
||||||
taskCompletionSource.SetResult(null);
|
taskCompletionSource.SetResult(null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -180,12 +180,14 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
readonly struct IncomingEventInfo
|
readonly struct IncomingEventInfo
|
||||||
{
|
{
|
||||||
public readonly int EventHandlerId;
|
public readonly int EventHandlerId;
|
||||||
|
public readonly EventFieldInfo EventFieldInfo;
|
||||||
public readonly UIEventArgs EventArgs;
|
public readonly UIEventArgs EventArgs;
|
||||||
public readonly TaskCompletionSource<object> TaskCompletionSource;
|
public readonly TaskCompletionSource<object> TaskCompletionSource;
|
||||||
|
|
||||||
public IncomingEventInfo(int eventHandlerId, UIEventArgs eventArgs)
|
public IncomingEventInfo(int eventHandlerId, EventFieldInfo eventFieldInfo, UIEventArgs eventArgs)
|
||||||
{
|
{
|
||||||
EventHandlerId = eventHandlerId;
|
EventHandlerId = eventHandlerId;
|
||||||
|
EventFieldInfo = eventFieldInfo;
|
||||||
EventArgs = eventArgs;
|
EventArgs = eventArgs;
|
||||||
TaskCompletionSource = new TaskCompletionSource<object>();
|
TaskCompletionSource = new TaskCompletionSource<object>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,7 @@ import { EventDelegator } from './EventDelegator';
|
||||||
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
||||||
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
|
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
|
||||||
import { applyCaptureIdToElement } from './ElementReferenceCapture';
|
import { applyCaptureIdToElement } from './ElementReferenceCapture';
|
||||||
|
import { EventFieldInfo } from './EventFieldInfo';
|
||||||
const selectValuePropname = '_blazorSelectValue';
|
const selectValuePropname = '_blazorSelectValue';
|
||||||
const sharedTemplateElemForParsing = document.createElement('template');
|
const sharedTemplateElemForParsing = document.createElement('template');
|
||||||
const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
|
@ -18,8 +19,8 @@ export class BrowserRenderer {
|
||||||
|
|
||||||
public constructor(browserRendererId: number) {
|
public constructor(browserRendererId: number) {
|
||||||
this.browserRendererId = browserRendererId;
|
this.browserRendererId = browserRendererId;
|
||||||
this.eventDelegator = new EventDelegator((event, eventHandlerId, eventArgs) => {
|
this.eventDelegator = new EventDelegator((event, eventHandlerId, eventArgs, eventFieldInfo) => {
|
||||||
raiseEvent(event, this.browserRendererId, eventHandlerId, eventArgs);
|
raiseEvent(event, this.browserRendererId, eventHandlerId, eventArgs, eventFieldInfo);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +51,7 @@ export class BrowserRenderer {
|
||||||
const ownerDocument = getClosestDomElement(element).ownerDocument;
|
const ownerDocument = getClosestDomElement(element).ownerDocument;
|
||||||
const activeElementBefore = ownerDocument && ownerDocument.activeElement;
|
const activeElementBefore = ownerDocument && ownerDocument.activeElement;
|
||||||
|
|
||||||
this.applyEdits(batch, element, 0, edits, referenceFrames);
|
this.applyEdits(batch, componentId, element, 0, edits, referenceFrames);
|
||||||
|
|
||||||
// Try to restore focus in case it was lost due to an element move
|
// Try to restore focus in case it was lost due to an element move
|
||||||
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
|
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
|
||||||
|
|
@ -70,7 +71,7 @@ export class BrowserRenderer {
|
||||||
this.childComponentLocations[componentId] = element;
|
this.childComponentLocations[componentId] = element;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyEdits(batch: RenderBatch, parent: LogicalElement, childIndex: number, edits: ArraySegment<RenderTreeEdit>, referenceFrames: ArrayValues<RenderTreeFrame>) {
|
private applyEdits(batch: RenderBatch, componentId: number, parent: LogicalElement, childIndex: number, edits: ArraySegment<RenderTreeEdit>, referenceFrames: ArrayValues<RenderTreeFrame>) {
|
||||||
let currentDepth = 0;
|
let currentDepth = 0;
|
||||||
let childIndexAtCurrentDepth = childIndex;
|
let childIndexAtCurrentDepth = childIndex;
|
||||||
let permutationList: PermutationListEntry[] | undefined;
|
let permutationList: PermutationListEntry[] | undefined;
|
||||||
|
|
@ -91,7 +92,7 @@ export class BrowserRenderer {
|
||||||
const frameIndex = editReader.newTreeIndex(edit);
|
const frameIndex = editReader.newTreeIndex(edit);
|
||||||
const frame = batch.referenceFramesEntry(referenceFrames, frameIndex);
|
const frame = batch.referenceFramesEntry(referenceFrames, frameIndex);
|
||||||
const siblingIndex = editReader.siblingIndex(edit);
|
const siblingIndex = editReader.siblingIndex(edit);
|
||||||
this.insertFrame(batch, parent, childIndexAtCurrentDepth + siblingIndex, referenceFrames, frame, frameIndex);
|
this.insertFrame(batch, componentId, parent, childIndexAtCurrentDepth + siblingIndex, referenceFrames, frame, frameIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EditType.removeFrame: {
|
case EditType.removeFrame: {
|
||||||
|
|
@ -105,7 +106,7 @@ export class BrowserRenderer {
|
||||||
const siblingIndex = editReader.siblingIndex(edit);
|
const siblingIndex = editReader.siblingIndex(edit);
|
||||||
const element = getLogicalChild(parent, childIndexAtCurrentDepth + siblingIndex);
|
const element = getLogicalChild(parent, childIndexAtCurrentDepth + siblingIndex);
|
||||||
if (element instanceof Element) {
|
if (element instanceof Element) {
|
||||||
this.applyAttribute(batch, element, frame);
|
this.applyAttribute(batch, componentId, element, frame);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Cannot set attribute on non-element child');
|
throw new Error('Cannot set attribute on non-element child');
|
||||||
}
|
}
|
||||||
|
|
@ -182,12 +183,12 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertFrame(batch: RenderBatch, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, frame: RenderTreeFrame, frameIndex: number): number {
|
private insertFrame(batch: RenderBatch, componentId: number, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, frame: RenderTreeFrame, frameIndex: number): number {
|
||||||
const frameReader = batch.frameReader;
|
const frameReader = batch.frameReader;
|
||||||
const frameType = frameReader.frameType(frame);
|
const frameType = frameReader.frameType(frame);
|
||||||
switch (frameType) {
|
switch (frameType) {
|
||||||
case FrameType.element:
|
case FrameType.element:
|
||||||
this.insertElement(batch, parent, childIndex, frames, frame, frameIndex);
|
this.insertElement(batch, componentId, parent, childIndex, frames, frame, frameIndex);
|
||||||
return 1;
|
return 1;
|
||||||
case FrameType.text:
|
case FrameType.text:
|
||||||
this.insertText(batch, parent, childIndex, frame);
|
this.insertText(batch, parent, childIndex, frame);
|
||||||
|
|
@ -198,7 +199,7 @@ export class BrowserRenderer {
|
||||||
this.insertComponent(batch, parent, childIndex, frame);
|
this.insertComponent(batch, parent, childIndex, frame);
|
||||||
return 1;
|
return 1;
|
||||||
case FrameType.region:
|
case FrameType.region:
|
||||||
return this.insertFrameRange(batch, parent, childIndex, frames, frameIndex + 1, frameIndex + frameReader.subtreeLength(frame));
|
return this.insertFrameRange(batch, componentId, parent, childIndex, frames, frameIndex + 1, frameIndex + frameReader.subtreeLength(frame));
|
||||||
case FrameType.elementReferenceCapture:
|
case FrameType.elementReferenceCapture:
|
||||||
if (parent instanceof Element) {
|
if (parent instanceof Element) {
|
||||||
applyCaptureIdToElement(parent, frameReader.elementReferenceCaptureId(frame)!);
|
applyCaptureIdToElement(parent, frameReader.elementReferenceCaptureId(frame)!);
|
||||||
|
|
@ -215,7 +216,7 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertElement(batch: RenderBatch, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, frame: RenderTreeFrame, frameIndex: number) {
|
private insertElement(batch: RenderBatch, componentId: number, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, frame: RenderTreeFrame, frameIndex: number) {
|
||||||
const frameReader = batch.frameReader;
|
const frameReader = batch.frameReader;
|
||||||
const tagName = frameReader.elementName(frame)!;
|
const tagName = frameReader.elementName(frame)!;
|
||||||
const newDomElementRaw = tagName === 'svg' || isSvgElement(parent) ?
|
const newDomElementRaw = tagName === 'svg' || isSvgElement(parent) ?
|
||||||
|
|
@ -229,11 +230,11 @@ export class BrowserRenderer {
|
||||||
for (let descendantIndex = frameIndex + 1; descendantIndex < descendantsEndIndexExcl; descendantIndex++) {
|
for (let descendantIndex = frameIndex + 1; descendantIndex < descendantsEndIndexExcl; descendantIndex++) {
|
||||||
const descendantFrame = batch.referenceFramesEntry(frames, descendantIndex);
|
const descendantFrame = batch.referenceFramesEntry(frames, descendantIndex);
|
||||||
if (frameReader.frameType(descendantFrame) === FrameType.attribute) {
|
if (frameReader.frameType(descendantFrame) === FrameType.attribute) {
|
||||||
this.applyAttribute(batch, newDomElementRaw, descendantFrame);
|
this.applyAttribute(batch, componentId, newDomElementRaw, descendantFrame);
|
||||||
} else {
|
} else {
|
||||||
// As soon as we see a non-attribute child, all the subsequent child frames are
|
// As soon as we see a non-attribute child, all the subsequent child frames are
|
||||||
// not attributes, so bail out and insert the remnants recursively
|
// not attributes, so bail out and insert the remnants recursively
|
||||||
this.insertFrameRange(batch, newElement, 0, frames, descendantIndex, descendantsEndIndexExcl);
|
this.insertFrameRange(batch, componentId, newElement, 0, frames, descendantIndex, descendantsEndIndexExcl);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +266,7 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyAttribute(batch: RenderBatch, toDomElement: Element, attributeFrame: RenderTreeFrame) {
|
private applyAttribute(batch: RenderBatch, componentId: number, toDomElement: Element, attributeFrame: RenderTreeFrame) {
|
||||||
const frameReader = batch.frameReader;
|
const frameReader = batch.frameReader;
|
||||||
const attributeName = frameReader.attributeName(attributeFrame)!;
|
const attributeName = frameReader.attributeName(attributeFrame)!;
|
||||||
const browserRendererId = this.browserRendererId;
|
const browserRendererId = this.browserRendererId;
|
||||||
|
|
@ -277,7 +278,7 @@ export class BrowserRenderer {
|
||||||
if (firstTwoChars !== 'on' || !eventName) {
|
if (firstTwoChars !== 'on' || !eventName) {
|
||||||
throw new Error(`Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.`);
|
throw new Error(`Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.`);
|
||||||
}
|
}
|
||||||
this.eventDelegator.setListener(toDomElement, eventName, eventHandlerId);
|
this.eventDelegator.setListener(toDomElement, eventName, eventHandlerId, componentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,11 +353,11 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertFrameRange(batch: RenderBatch, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, startIndex: number, endIndexExcl: number): number {
|
private insertFrameRange(batch: RenderBatch, componentId: number, parent: LogicalElement, childIndex: number, frames: ArrayValues<RenderTreeFrame>, startIndex: number, endIndexExcl: number): number {
|
||||||
const origChildIndex = childIndex;
|
const origChildIndex = childIndex;
|
||||||
for (let index = startIndex; index < endIndexExcl; index++) {
|
for (let index = startIndex; index < endIndexExcl; index++) {
|
||||||
const frame = batch.referenceFramesEntry(frames, index);
|
const frame = batch.referenceFramesEntry(frames, index);
|
||||||
const numChildrenInserted = this.insertFrame(batch, parent, childIndex, frames, frame, index);
|
const numChildrenInserted = this.insertFrame(batch, componentId, parent, childIndex, frames, frame, index);
|
||||||
childIndex += numChildrenInserted;
|
childIndex += numChildrenInserted;
|
||||||
|
|
||||||
// Skip over any descendants, since they are already dealt with recursively
|
// Skip over any descendants, since they are already dealt with recursively
|
||||||
|
|
@ -397,7 +398,7 @@ function countDescendantFrames(batch: RenderBatch, frame: RenderTreeFrame): numb
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function raiseEvent(event: Event, browserRendererId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>) {
|
function raiseEvent(event: Event, browserRendererId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>, eventFieldInfo: EventFieldInfo | null) {
|
||||||
if (preventDefaultEvents[event.type]) {
|
if (preventDefaultEvents[event.type]) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +407,7 @@ function raiseEvent(event: Event, browserRendererId: number, eventHandlerId: num
|
||||||
browserRendererId,
|
browserRendererId,
|
||||||
eventHandlerId,
|
eventHandlerId,
|
||||||
eventArgsType: eventArgs.type,
|
eventArgsType: eventArgs.type,
|
||||||
|
eventFieldInfo: eventFieldInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
return DotNet.invokeMethodAsync(
|
return DotNet.invokeMethodAsync(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
||||||
|
import { EventFieldInfo } from './EventFieldInfo';
|
||||||
|
|
||||||
const nonBubblingEvents = toLookup([
|
const nonBubblingEvents = toLookup([
|
||||||
'abort',
|
'abort',
|
||||||
|
|
@ -21,7 +22,7 @@ const nonBubblingEvents = toLookup([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface OnEventCallback {
|
export interface OnEventCallback {
|
||||||
(event: Event, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>): void;
|
(event: Event, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>, eventFieldInfo: EventFieldInfo | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsible for adding/removing the eventInfo on an expando property on DOM elements, and
|
// Responsible for adding/removing the eventInfo on an expando property on DOM elements, and
|
||||||
|
|
@ -40,7 +41,7 @@ export class EventDelegator {
|
||||||
this.eventInfoStore = new EventInfoStore(this.onGlobalEvent.bind(this));
|
this.eventInfoStore = new EventInfoStore(this.onGlobalEvent.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
public setListener(element: Element, eventName: string, eventHandlerId: number) {
|
public setListener(element: Element, eventName: string, eventHandlerId: number, renderingComponentId: number) {
|
||||||
// Ensure we have a place to store event info for this element
|
// Ensure we have a place to store event info for this element
|
||||||
let infoForElement: EventHandlerInfosForElement = element[this.eventsCollectionKey];
|
let infoForElement: EventHandlerInfosForElement = element[this.eventsCollectionKey];
|
||||||
if (!infoForElement) {
|
if (!infoForElement) {
|
||||||
|
|
@ -53,7 +54,7 @@ export class EventDelegator {
|
||||||
this.eventInfoStore.update(oldInfo.eventHandlerId, eventHandlerId);
|
this.eventInfoStore.update(oldInfo.eventHandlerId, eventHandlerId);
|
||||||
} else {
|
} else {
|
||||||
// Go through the whole flow which might involve registering a new global handler
|
// Go through the whole flow which might involve registering a new global handler
|
||||||
const newInfo = { element, eventName, eventHandlerId };
|
const newInfo = { element, eventName, eventHandlerId, renderingComponentId };
|
||||||
this.eventInfoStore.add(newInfo);
|
this.eventInfoStore.add(newInfo);
|
||||||
infoForElement[eventName] = newInfo;
|
infoForElement[eventName] = newInfo;
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +90,7 @@ export class EventDelegator {
|
||||||
const eventIsNonBubbling = nonBubblingEvents.hasOwnProperty(evt.type);
|
const eventIsNonBubbling = nonBubblingEvents.hasOwnProperty(evt.type);
|
||||||
while (candidateElement) {
|
while (candidateElement) {
|
||||||
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
|
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
|
||||||
const handlerInfos = candidateElement[this.eventsCollectionKey];
|
const handlerInfos: EventHandlerInfosForElement = candidateElement[this.eventsCollectionKey];
|
||||||
if (handlerInfos.hasOwnProperty(evt.type)) {
|
if (handlerInfos.hasOwnProperty(evt.type)) {
|
||||||
// We are going to raise an event for this element, so prepare info needed by the .NET code
|
// We are going to raise an event for this element, so prepare info needed by the .NET code
|
||||||
if (!eventArgs) {
|
if (!eventArgs) {
|
||||||
|
|
@ -97,7 +98,8 @@ export class EventDelegator {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlerInfo = handlerInfos[evt.type];
|
const handlerInfo = handlerInfos[evt.type];
|
||||||
this.onEvent(evt, handlerInfo.eventHandlerId, eventArgs);
|
const eventFieldInfo = EventFieldInfo.fromEvent(handlerInfo.renderingComponentId, evt);
|
||||||
|
this.onEvent(evt, handlerInfo.eventHandlerId, eventArgs, eventFieldInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,6 +182,11 @@ interface EventHandlerInfo {
|
||||||
element: Element;
|
element: Element;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
eventHandlerId: number;
|
eventHandlerId: number;
|
||||||
|
|
||||||
|
// The component whose tree includes the event handler attribute frame, *not* necessarily the
|
||||||
|
// same component that will be re-rendered after the event is handled (since we re-render the
|
||||||
|
// component that supplied the delegate, not the one that rendered the event handler frame)
|
||||||
|
renderingComponentId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLookup(items: string[]): { [key: string]: boolean } {
|
function toLookup(items: string[]): { [key: string]: boolean } {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
export class EventFieldInfo {
|
||||||
|
constructor(public componentId: number, public fieldValue: string | boolean) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromEvent(componentId: number, event: Event): EventFieldInfo | null {
|
||||||
|
const elem = event.target;
|
||||||
|
if (elem instanceof Element) {
|
||||||
|
const fieldData = getFormFieldData(elem);
|
||||||
|
if (fieldData) {
|
||||||
|
return new EventFieldInfo(componentId, fieldData.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This event isn't happening on a form field that we can reverse-map back to some incoming attribute
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormFieldData(elem: Element) {
|
||||||
|
// The logic in here should be the inverse of the logic in BrowserRenderer's tryApplySpecialProperty.
|
||||||
|
// That is, we're doing the reverse mapping, starting from an HTML property and reconstructing which
|
||||||
|
// "special" attribute would have been mapped to that property.
|
||||||
|
if (elem instanceof HTMLInputElement) {
|
||||||
|
return (elem.type && elem.type.toLowerCase() === 'checkbox')
|
||||||
|
? { value: elem.checked }
|
||||||
|
: { value: elem.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elem instanceof HTMLSelectElement || elem instanceof HTMLTextAreaElement) {
|
||||||
|
return { value: elem.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Components.Browser
|
||||||
public BrowserEventDescriptor() { }
|
public BrowserEventDescriptor() { }
|
||||||
public int BrowserRendererId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
public int BrowserRendererId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
public string EventArgsType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
public string EventArgsType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
|
public Microsoft.AspNetCore.Components.Rendering.EventFieldInfo EventFieldInfo { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
public int EventHandlerId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
public int EventHandlerId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,38 @@ namespace Microsoft.AspNetCore.Components.Browser
|
||||||
public static Task DispatchEvent(
|
public static Task DispatchEvent(
|
||||||
BrowserEventDescriptor eventDescriptor, string eventArgsJson)
|
BrowserEventDescriptor eventDescriptor, string eventArgsJson)
|
||||||
{
|
{
|
||||||
|
InterpretEventDescriptor(eventDescriptor);
|
||||||
var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson);
|
var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson);
|
||||||
var renderer = RendererRegistry.Current.Find(eventDescriptor.BrowserRendererId);
|
var renderer = RendererRegistry.Current.Find(eventDescriptor.BrowserRendererId);
|
||||||
return renderer.DispatchEventAsync(eventDescriptor.EventHandlerId, eventArgs);
|
return renderer.DispatchEventAsync(eventDescriptor.EventHandlerId, eventDescriptor.EventFieldInfo, eventArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InterpretEventDescriptor(BrowserEventDescriptor eventDescriptor)
|
||||||
|
{
|
||||||
|
// The incoming field value can be either a bool or a string, but since the .NET property
|
||||||
|
// type is 'object', it will deserialize initially as a JsonElement
|
||||||
|
var fieldInfo = eventDescriptor.EventFieldInfo;
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
if (fieldInfo.FieldValue is JsonElement attributeValueJsonElement)
|
||||||
|
{
|
||||||
|
switch (attributeValueJsonElement.Type)
|
||||||
|
{
|
||||||
|
case JsonValueType.True:
|
||||||
|
case JsonValueType.False:
|
||||||
|
fieldInfo.FieldValue = attributeValueJsonElement.GetBoolean();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fieldInfo.FieldValue = attributeValueJsonElement.GetString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unanticipated value type. Ensure we don't do anything with it.
|
||||||
|
eventDescriptor.EventFieldInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UIEventArgs ParseEventArgsJson(string eventArgsType, string eventArgsJson)
|
private static UIEventArgs ParseEventArgsJson(string eventArgsType, string eventArgsJson)
|
||||||
|
|
@ -105,6 +134,11 @@ namespace Microsoft.AspNetCore.Components.Browser
|
||||||
/// For framework use only.
|
/// For framework use only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string EventArgsType { get; set; }
|
public string EventArgsType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For framework use only.
|
||||||
|
/// </summary>
|
||||||
|
public EventFieldInfo EventFieldInfo { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -688,6 +688,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
public int ComponentId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
|
public int ComponentId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
|
||||||
public System.Collections.Generic.IEnumerable<string> Tokens { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
|
public System.Collections.Generic.IEnumerable<string> Tokens { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
|
||||||
}
|
}
|
||||||
|
public partial class EventFieldInfo
|
||||||
|
{
|
||||||
|
public EventFieldInfo() { }
|
||||||
|
public int ComponentId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
|
public object FieldValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
|
||||||
|
}
|
||||||
public partial class HtmlRenderer : Microsoft.AspNetCore.Components.Rendering.Renderer
|
public partial class HtmlRenderer : Microsoft.AspNetCore.Components.Rendering.Renderer
|
||||||
{
|
{
|
||||||
public HtmlRenderer(System.IServiceProvider serviceProvider, System.Func<string, string> htmlEncoder, Microsoft.AspNetCore.Components.Rendering.IDispatcher dispatcher) : base (default(System.IServiceProvider)) { }
|
public HtmlRenderer(System.IServiceProvider serviceProvider, System.Func<string, string> htmlEncoder, Microsoft.AspNetCore.Components.Rendering.IDispatcher dispatcher) : base (default(System.IServiceProvider)) { }
|
||||||
|
|
@ -721,7 +727,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { }
|
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { }
|
||||||
protected internal int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
|
protected internal int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
|
||||||
public static Microsoft.AspNetCore.Components.Rendering.IDispatcher CreateDefaultDispatcher() { throw null; }
|
public static Microsoft.AspNetCore.Components.Rendering.IDispatcher CreateDefaultDispatcher() { throw null; }
|
||||||
public virtual System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
|
public virtual System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.Rendering.EventFieldInfo fieldInfo, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
protected virtual void Dispose(bool disposing) { }
|
protected virtual void Dispose(bool disposing) { }
|
||||||
protected abstract void HandleException(System.Exception exception);
|
protected abstract void HandleException(System.Exception exception);
|
||||||
|
|
|
||||||
|
|
@ -687,13 +687,26 @@ namespace Microsoft.AspNetCore.Components
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
// We only invoke the setter if the conversion didn't throw. This is valuable because it allows us to attempt
|
// We only invoke the setter if the conversion didn't throw, or if the newly-entered value is empty.
|
||||||
// to process invalid input but avoid dirtying the state of the component if can't be converted. Imagine if
|
// If the user entered some non-empty value we couldn't parse, we leave the state of the .NET field
|
||||||
// we assigned default(T) on failure - this would result in trouncing the user's typed in value.
|
// unchanged, which for a two-way binding results in the UI reverting to its previous valid state
|
||||||
|
// because the diff will see the current .NET output no longer matches the render tree since we
|
||||||
|
// patched it to reflect the state of the UI.
|
||||||
|
//
|
||||||
|
// This reversion behavior is valuable because alternatives are problematic:
|
||||||
|
// - If we assigned default(T) on failure, the user would lose whatever data they were editing,
|
||||||
|
// for example if they accidentally pressed an alphabetical key while editing a number with
|
||||||
|
// @bind:event="oninput"
|
||||||
|
// - If the diff mechanism didn't revert to the previous good value, the user wouldn't necessarily
|
||||||
|
// know that the data they are submitting is different from what they think they've typed
|
||||||
if (converted)
|
if (converted)
|
||||||
{
|
{
|
||||||
setter(value);
|
setter(value);
|
||||||
}
|
}
|
||||||
|
else if (string.Empty.Equals(e.Value))
|
||||||
|
{
|
||||||
|
setter(default);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return factory.Create<UIChangeEventArgs>(receiver, callback);
|
return factory.Create<UIChangeEventArgs>(receiver, callback);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,25 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
_items[_itemsInUse] = default(T); // Release to GC
|
_items[_itemsInUse] = default(T); // Release to GC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts the item at the specified index, moving the contents of the subsequent entries along by one.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="insertAtIndex">The index at which the value is to be inserted.</param>
|
||||||
|
/// <param name="value">The value to insert.</param>
|
||||||
|
public void InsertExpensive(int insertAtIndex, T value)
|
||||||
|
{
|
||||||
|
// Same expansion logic as elsewhere
|
||||||
|
if (_itemsInUse == _items.Length)
|
||||||
|
{
|
||||||
|
SetCapacity(_items.Length * 2, preserveContents: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Copy(_items, insertAtIndex, _items, insertAtIndex + 1, _itemsInUse - insertAtIndex);
|
||||||
|
_itemsInUse++;
|
||||||
|
|
||||||
|
_items[insertAtIndex] = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks the array as empty, also shrinking the underlying storage if it was
|
/// Marks the array as empty, also shrinking the underlying storage if it was
|
||||||
/// not being used to near its full capacity.
|
/// not being used to near its full capacity.
|
||||||
|
|
|
||||||
|
|
@ -521,8 +521,18 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
/// <param name="updatesAttributeName">The name of another attribute whose value can be updated when the event handler is executed.</param>
|
/// <param name="updatesAttributeName">The name of another attribute whose value can be updated when the event handler is executed.</param>
|
||||||
public void SetUpdatesAttributeName(string updatesAttributeName)
|
public void SetUpdatesAttributeName(string updatesAttributeName)
|
||||||
{
|
{
|
||||||
// TODO: This will be implemented in a later PR, once aspnetcore-tooling
|
if (_entries.Count == 0)
|
||||||
// is updated to call this method.
|
{
|
||||||
|
throw new InvalidOperationException("No preceding attribute frame exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var prevFrame = ref _entries.Buffer[_entries.Count - 1];
|
||||||
|
if (prevFrame.FrameType != RenderTreeFrameType.Attribute)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Incorrect frame type: '{prevFrame.FrameType}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
prevFrame = prevFrame.WithAttributeEventUpdatesAttributeName(updatesAttributeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -699,6 +709,19 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
_seenAttributeNames?.Clear();
|
_seenAttributeNames?.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// internal because this should only be used during the post-event tree patching logic
|
||||||
|
// It's expensive because it involves copying all the subsequent memory in the array
|
||||||
|
internal void InsertAttributeExpensive(int insertAtIndex, int sequence, string attributeName, object attributeValue)
|
||||||
|
{
|
||||||
|
// Replicate the same attribute omission logic as used elsewhere
|
||||||
|
if ((attributeValue == null) || (attributeValue is bool boolValue && !boolValue))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries.InsertExpensive(insertAtIndex, RenderTreeFrame.Attribute(sequence, attributeName, attributeValue));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the <see cref="RenderTreeFrame"/> values that have been appended.
|
/// Returns the <see cref="RenderTreeFrame"/> values that have been appended.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
{
|
{
|
||||||
enum DiffAction { Match, Insert, Delete }
|
enum DiffAction { Match, Insert, Delete }
|
||||||
|
|
||||||
|
// We use int.MinValue to signal this special case because (1) it would never be used by
|
||||||
|
// the Razor compiler or by accident in developer code, and (2) we know it will always
|
||||||
|
// hit the "old < new" code path during diffing so we only have to check for it in one place.
|
||||||
|
public const int SystemAddedAttributeSequenceNumber = int.MinValue;
|
||||||
|
|
||||||
public static RenderTreeDiff ComputeDiff(
|
public static RenderTreeDiff ComputeDiff(
|
||||||
Renderer renderer,
|
Renderer renderer,
|
||||||
RenderBatchBuilder batchBuilder,
|
RenderBatchBuilder batchBuilder,
|
||||||
|
|
@ -382,6 +387,21 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
}
|
}
|
||||||
else if (oldSeq < newSeq)
|
else if (oldSeq < newSeq)
|
||||||
{
|
{
|
||||||
|
if (oldSeq == SystemAddedAttributeSequenceNumber)
|
||||||
|
{
|
||||||
|
// This special sequence number means that we can't rely on the sequence numbers
|
||||||
|
// for matching and are forced to fall back on the dictionary-based join in order
|
||||||
|
// to produce an optimal diff. If we didn't we'd likely produce a diff that removes
|
||||||
|
// and then re-adds the same attribute.
|
||||||
|
// We use the special sequence number to signal it because it adds almost no cost
|
||||||
|
// to check for it only in this one case.
|
||||||
|
AppendAttributeDiffEntriesForRangeSlow(
|
||||||
|
ref diffContext,
|
||||||
|
oldStartIndex, oldEndIndexExcl,
|
||||||
|
newStartIndex, newEndIndexExcl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// An attribute was removed compared to the old sequence.
|
// An attribute was removed compared to the old sequence.
|
||||||
RemoveOldFrame(ref diffContext, oldStartIndex);
|
RemoveOldFrame(ref diffContext, oldStartIndex);
|
||||||
|
|
||||||
|
|
@ -661,13 +681,17 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
var valueChanged = !Equals(oldFrame.AttributeValue, newFrame.AttributeValue);
|
var valueChanged = !Equals(oldFrame.AttributeValue, newFrame.AttributeValue);
|
||||||
if (valueChanged)
|
if (valueChanged)
|
||||||
{
|
{
|
||||||
if (oldFrame.AttributeEventHandlerId > 0)
|
|
||||||
{
|
|
||||||
diffContext.BatchBuilder.DisposedEventHandlerIds.Append(oldFrame.AttributeEventHandlerId);
|
|
||||||
}
|
|
||||||
InitializeNewAttributeFrame(ref diffContext, ref newFrame);
|
InitializeNewAttributeFrame(ref diffContext, ref newFrame);
|
||||||
var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame);
|
var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame);
|
||||||
diffContext.Edits.Append(RenderTreeEdit.SetAttribute(diffContext.SiblingIndex, referenceFrameIndex));
|
diffContext.Edits.Append(RenderTreeEdit.SetAttribute(diffContext.SiblingIndex, referenceFrameIndex));
|
||||||
|
|
||||||
|
// If we're replacing an old event handler ID with a new one, register the old one for disposal,
|
||||||
|
// plus keep track of the old->new chain until the old one is fully disposed
|
||||||
|
if (oldFrame.AttributeEventHandlerId > 0)
|
||||||
|
{
|
||||||
|
diffContext.Renderer.TrackReplacedEventHandlerId(oldFrame.AttributeEventHandlerId, newFrame.AttributeEventHandlerId);
|
||||||
|
diffContext.BatchBuilder.DisposedEventHandlerIds.Append(oldFrame.AttributeEventHandlerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (oldFrame.AttributeEventHandlerId > 0)
|
else if (oldFrame.AttributeEventHandlerId > 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[FieldOffset(24)] public readonly object AttributeValue;
|
[FieldOffset(24)] public readonly object AttributeValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the <see cref="FrameType"/> property equals <see cref="RenderTreeFrameType.Attribute"/>,
|
||||||
|
/// and the attribute represents an event handler, gets the name of another attribute whose value
|
||||||
|
/// can be updated to represent the UI state prior to executing the event handler. This is
|
||||||
|
/// primarily used in two-way bindings.
|
||||||
|
/// </summary>
|
||||||
|
[FieldOffset(32)] public readonly string AttributeEventUpdatesAttributeName;
|
||||||
|
|
||||||
// --------------------------------------------------------------------------------
|
// --------------------------------------------------------------------------------
|
||||||
// RenderTreeFrameType.Component
|
// RenderTreeFrameType.Component
|
||||||
// --------------------------------------------------------------------------------
|
// --------------------------------------------------------------------------------
|
||||||
|
|
@ -259,7 +267,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attribute constructor
|
// Attribute constructor
|
||||||
private RenderTreeFrame(int sequence, string attributeName, object attributeValue, int attributeEventHandlerId)
|
private RenderTreeFrame(int sequence, string attributeName, object attributeValue, int attributeEventHandlerId, string attributeEventUpdatesAttributeName)
|
||||||
: this()
|
: this()
|
||||||
{
|
{
|
||||||
FrameType = RenderTreeFrameType.Attribute;
|
FrameType = RenderTreeFrameType.Attribute;
|
||||||
|
|
@ -267,6 +275,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
AttributeName = attributeName;
|
AttributeName = attributeName;
|
||||||
AttributeValue = attributeValue;
|
AttributeValue = attributeValue;
|
||||||
AttributeEventHandlerId = attributeEventHandlerId;
|
AttributeEventHandlerId = attributeEventHandlerId;
|
||||||
|
AttributeEventUpdatesAttributeName = attributeEventUpdatesAttributeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element reference capture constructor
|
// Element reference capture constructor
|
||||||
|
|
@ -299,7 +308,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
=> new RenderTreeFrame(sequence, isMarkup: true, textOrMarkup: markupContent);
|
=> new RenderTreeFrame(sequence, isMarkup: true, textOrMarkup: markupContent);
|
||||||
|
|
||||||
internal static RenderTreeFrame Attribute(int sequence, string name, object value)
|
internal static RenderTreeFrame Attribute(int sequence, string name, object value)
|
||||||
=> new RenderTreeFrame(sequence, attributeName: name, attributeValue: value, attributeEventHandlerId: 0);
|
=> new RenderTreeFrame(sequence, attributeName: name, attributeValue: value, attributeEventHandlerId: 0, attributeEventUpdatesAttributeName: null);
|
||||||
|
|
||||||
internal static RenderTreeFrame ChildComponent(int sequence, Type componentType)
|
internal static RenderTreeFrame ChildComponent(int sequence, Type componentType)
|
||||||
=> new RenderTreeFrame(sequence, componentSubtreeLength: 0, componentType, null, null);
|
=> new RenderTreeFrame(sequence, componentSubtreeLength: 0, componentType, null, null);
|
||||||
|
|
@ -323,13 +332,19 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
||||||
=> new RenderTreeFrame(Sequence, componentSubtreeLength: componentSubtreeLength, ComponentType, ComponentState, ComponentKey);
|
=> new RenderTreeFrame(Sequence, componentSubtreeLength: componentSubtreeLength, ComponentType, ComponentState, ComponentKey);
|
||||||
|
|
||||||
internal RenderTreeFrame WithAttributeSequence(int sequence)
|
internal RenderTreeFrame WithAttributeSequence(int sequence)
|
||||||
=> new RenderTreeFrame(sequence, attributeName: AttributeName, AttributeValue, AttributeEventHandlerId);
|
=> new RenderTreeFrame(sequence, attributeName: AttributeName, AttributeValue, AttributeEventHandlerId, AttributeEventUpdatesAttributeName);
|
||||||
|
|
||||||
internal RenderTreeFrame WithComponent(ComponentState componentState)
|
internal RenderTreeFrame WithComponent(ComponentState componentState)
|
||||||
=> new RenderTreeFrame(Sequence, componentSubtreeLength: ComponentSubtreeLength, ComponentType, componentState, ComponentKey);
|
=> new RenderTreeFrame(Sequence, componentSubtreeLength: ComponentSubtreeLength, ComponentType, componentState, ComponentKey);
|
||||||
|
|
||||||
internal RenderTreeFrame WithAttributeEventHandlerId(int eventHandlerId)
|
internal RenderTreeFrame WithAttributeEventHandlerId(int eventHandlerId)
|
||||||
=> new RenderTreeFrame(Sequence, attributeName: AttributeName, AttributeValue, eventHandlerId);
|
=> new RenderTreeFrame(Sequence, attributeName: AttributeName, AttributeValue, eventHandlerId, AttributeEventUpdatesAttributeName);
|
||||||
|
|
||||||
|
internal RenderTreeFrame WithAttributeValue(object attributeValue)
|
||||||
|
=> new RenderTreeFrame(Sequence, attributeName: AttributeName, attributeValue, AttributeEventHandlerId, AttributeEventUpdatesAttributeName);
|
||||||
|
|
||||||
|
internal RenderTreeFrame WithAttributeEventUpdatesAttributeName(string attributeUpdatesAttributeName)
|
||||||
|
=> new RenderTreeFrame(Sequence, attributeName: AttributeName, AttributeValue, AttributeEventHandlerId, attributeUpdatesAttributeName);
|
||||||
|
|
||||||
internal RenderTreeFrame WithRegionSubtreeLength(int regionSubtreeLength)
|
internal RenderTreeFrame WithRegionSubtreeLength(int regionSubtreeLength)
|
||||||
=> new RenderTreeFrame(Sequence, regionSubtreeLength: regionSubtreeLength);
|
=> new RenderTreeFrame(Sequence, regionSubtreeLength: regionSubtreeLength);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Information supplied with an event notification that can be used to update an existing
|
||||||
|
/// render tree to match the latest UI state when a form field has mutated. To determine
|
||||||
|
/// which field has been mutated, the renderer matches it based on the event handler ID.
|
||||||
|
/// </summary>
|
||||||
|
public class EventFieldInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the component whose render tree contains the affected form field.
|
||||||
|
/// </summary>
|
||||||
|
public int ComponentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies the form field's new value.
|
||||||
|
/// </summary>
|
||||||
|
public object FieldValue { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.RenderTree;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
|
{
|
||||||
|
internal class RenderTreeUpdater
|
||||||
|
{
|
||||||
|
public static void UpdateToMatchClientState(RenderTreeBuilder renderTreeBuilder, int eventHandlerId, object newFieldValue)
|
||||||
|
{
|
||||||
|
// We only allow the client to supply string or bool currently, since those are the only kinds of
|
||||||
|
// values we output on attributes that go to the client
|
||||||
|
if (!(newFieldValue is string || newFieldValue is bool))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the element that contains the event handler
|
||||||
|
var frames = renderTreeBuilder.GetFrames();
|
||||||
|
var framesArray = frames.Array;
|
||||||
|
var framesLength = frames.Count;
|
||||||
|
var closestElementFrameIndex = -1;
|
||||||
|
for (var frameIndex = 0; frameIndex < framesLength; frameIndex++)
|
||||||
|
{
|
||||||
|
ref var frame = ref framesArray[frameIndex];
|
||||||
|
switch (frame.FrameType)
|
||||||
|
{
|
||||||
|
case RenderTreeFrameType.Element:
|
||||||
|
closestElementFrameIndex = frameIndex;
|
||||||
|
break;
|
||||||
|
case RenderTreeFrameType.Attribute:
|
||||||
|
if (frame.AttributeEventHandlerId == eventHandlerId)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(frame.AttributeEventUpdatesAttributeName))
|
||||||
|
{
|
||||||
|
UpdateFrameToMatchClientState(
|
||||||
|
renderTreeBuilder,
|
||||||
|
framesArray,
|
||||||
|
closestElementFrameIndex,
|
||||||
|
frame.AttributeEventUpdatesAttributeName,
|
||||||
|
newFieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether or not we did update the frame, that was the one that matches
|
||||||
|
// the event handler ID, so no need to look any further
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateFrameToMatchClientState(RenderTreeBuilder renderTreeBuilder, RenderTreeFrame[] framesArray, int elementFrameIndex, string attributeName, object attributeValue)
|
||||||
|
{
|
||||||
|
// Find the attribute frame
|
||||||
|
ref var elementFrame = ref framesArray[elementFrameIndex];
|
||||||
|
var elementSubtreeEndIndexExcl = elementFrameIndex + elementFrame.ElementSubtreeLength;
|
||||||
|
for (var attributeFrameIndex = elementFrameIndex + 1; attributeFrameIndex < elementSubtreeEndIndexExcl; attributeFrameIndex++)
|
||||||
|
{
|
||||||
|
ref var attributeFrame = ref framesArray[attributeFrameIndex];
|
||||||
|
if (attributeFrame.FrameType != RenderTreeFrameType.Attribute)
|
||||||
|
{
|
||||||
|
// We're now looking at the descendants not attributes, so the search is over
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributeFrame.AttributeName == attributeName)
|
||||||
|
{
|
||||||
|
// Found an existing attribute we can update
|
||||||
|
attributeFrame = attributeFrame.WithAttributeValue(attributeValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we didn't find the desired attribute, so we have to insert a new frame for it
|
||||||
|
var insertAtIndex = elementFrameIndex + 1;
|
||||||
|
renderTreeBuilder.InsertAttributeExpensive(insertAtIndex, RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber, attributeName, attributeValue);
|
||||||
|
framesArray = renderTreeBuilder.GetFrames().Array; // Refresh in case it mutated due to the expansion
|
||||||
|
|
||||||
|
// Update subtree length for this and all ancestor containers
|
||||||
|
// Ancestors can only be regions or other elements, since components can't "contain" elements inline
|
||||||
|
// We only have to walk backwards, since later entries in the frames array can't contain an earlier one
|
||||||
|
for (var otherFrameIndex = elementFrameIndex; otherFrameIndex >= 0; otherFrameIndex--)
|
||||||
|
{
|
||||||
|
ref var otherFrame = ref framesArray[otherFrameIndex];
|
||||||
|
switch (otherFrame.FrameType)
|
||||||
|
{
|
||||||
|
case RenderTreeFrameType.Element:
|
||||||
|
{
|
||||||
|
var otherFrameSubtreeLength = otherFrame.ElementSubtreeLength;
|
||||||
|
var otherFrameEndIndexExcl = otherFrameIndex + otherFrameSubtreeLength;
|
||||||
|
if (otherFrameEndIndexExcl > elementFrameIndex) // i.e., contains the element we're inserting into
|
||||||
|
{
|
||||||
|
otherFrame = otherFrame.WithElementSubtreeLength(otherFrameSubtreeLength + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case RenderTreeFrameType.Region:
|
||||||
|
{
|
||||||
|
var otherFrameSubtreeLength = otherFrame.RegionSubtreeLength;
|
||||||
|
var otherFrameEndIndexExcl = otherFrameIndex + otherFrameSubtreeLength;
|
||||||
|
if (otherFrameEndIndexExcl > elementFrameIndex) // i.e., contains the element we're inserting into
|
||||||
|
{
|
||||||
|
otherFrame = otherFrame.WithRegionSubtreeLength(otherFrameSubtreeLength + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
|
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
|
||||||
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
|
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
|
||||||
private readonly Dictionary<int, EventCallback> _eventBindings = new Dictionary<int, EventCallback>();
|
private readonly Dictionary<int, EventCallback> _eventBindings = new Dictionary<int, EventCallback>();
|
||||||
|
private readonly Dictionary<int, int> _eventHandlerIdReplacements = new Dictionary<int, int>();
|
||||||
private readonly IDispatcher _dispatcher;
|
private readonly IDispatcher _dispatcher;
|
||||||
|
|
||||||
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
|
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
|
||||||
|
|
@ -205,11 +206,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="eventHandlerId">The <see cref="RenderTreeFrame.AttributeEventHandlerId"/> value from the original event attribute.</param>
|
/// <param name="eventHandlerId">The <see cref="RenderTreeFrame.AttributeEventHandlerId"/> value from the original event attribute.</param>
|
||||||
/// <param name="eventArgs">Arguments to be passed to the event handler.</param>
|
/// <param name="eventArgs">Arguments to be passed to the event handler.</param>
|
||||||
|
/// <param name="fieldInfo">Information that the renderer can use to update the state of the existing render tree to match the UI.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// A <see cref="Task"/> which will complete once all asynchronous processing related to the event
|
/// A <see cref="Task"/> which will complete once all asynchronous processing related to the event
|
||||||
/// has completed.
|
/// has completed.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public virtual Task DispatchEventAsync(int eventHandlerId, UIEventArgs eventArgs)
|
public virtual Task DispatchEventAsync(int eventHandlerId, EventFieldInfo fieldInfo, UIEventArgs eventArgs)
|
||||||
{
|
{
|
||||||
EnsureSynchronizationContext();
|
EnsureSynchronizationContext();
|
||||||
|
|
||||||
|
|
@ -218,6 +220,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
throw new ArgumentException($"There is no event handler with ID {eventHandlerId}");
|
throw new ArgumentException($"There is no event handler with ID {eventHandlerId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
var latestEquivalentEventHandlerId = FindLatestEventHandlerIdInChain(eventHandlerId);
|
||||||
|
UpdateRenderTreeToMatchClientState(latestEquivalentEventHandlerId, fieldInfo);
|
||||||
|
}
|
||||||
|
|
||||||
Task task = null;
|
Task task = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -401,6 +409,24 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void TrackReplacedEventHandlerId(int oldEventHandlerId, int newEventHandlerId)
|
||||||
|
{
|
||||||
|
// Tracking the chain of old->new replacements allows us to interpret incoming EventFieldInfo
|
||||||
|
// values even if they refer to an event handler ID that's since been superseded. This is essential
|
||||||
|
// for tree patching to work in an async environment.
|
||||||
|
_eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int FindLatestEventHandlerIdInChain(int eventHandlerId)
|
||||||
|
{
|
||||||
|
while (_eventHandlerIdReplacements.TryGetValue(eventHandlerId, out var replacementEventHandlerId))
|
||||||
|
{
|
||||||
|
eventHandlerId = replacementEventHandlerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventHandlerId;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureSynchronizationContext()
|
private void EnsureSynchronizationContext()
|
||||||
{
|
{
|
||||||
// When the IDispatcher is a synchronization context
|
// When the IDispatcher is a synchronization context
|
||||||
|
|
@ -613,7 +639,9 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
var count = eventHandlerIds.Count;
|
var count = eventHandlerIds.Count;
|
||||||
for (var i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
_eventBindings.Remove(array[i]);
|
var eventHandlerIdToRemove = array[i];
|
||||||
|
_eventBindings.Remove(eventHandlerIdToRemove);
|
||||||
|
_eventHandlerIdReplacements.Remove(eventHandlerIdToRemove);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -662,6 +690,18 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateRenderTreeToMatchClientState(int eventHandlerId, EventFieldInfo fieldInfo)
|
||||||
|
{
|
||||||
|
var componentState = GetOptionalComponentState(fieldInfo.ComponentId);
|
||||||
|
if (componentState != null)
|
||||||
|
{
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(
|
||||||
|
componentState.CurrrentRenderTree,
|
||||||
|
eventHandlerId,
|
||||||
|
fieldInfo.FieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Releases all resources currently used by this <see cref="Renderer"/> instance.
|
/// Releases all resources currently used by this <see cref="Renderer"/> instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,23 @@ namespace Microsoft.AspNetCore.Components
|
||||||
Assert.Equal(1, component.Count);
|
Assert.Equal(1, component.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateBinder_IfConverterThrows_ConvertsEmptyStringToDefault()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var value = 17;
|
||||||
|
var component = new EventCountingComponent();
|
||||||
|
Action<int> setter = (_) => value = _;
|
||||||
|
|
||||||
|
var binder = EventCallback.Factory.CreateBinder(component, setter, value);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await binder.InvokeAsync(new UIChangeEventArgs() { Value = string.Empty, });
|
||||||
|
|
||||||
|
Assert.Equal(0, value); // Calls setter to apply default value for this type
|
||||||
|
Assert.Equal(1, component.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateBinder_ThrowsSetterException()
|
public async Task CreateBinder_ThrowsSetterException()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using Microsoft.AspNetCore.Components.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Components.RenderTree;
|
||||||
|
using Microsoft.AspNetCore.Components.Test.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.Test
|
||||||
|
{
|
||||||
|
public class RenderTreeUpdaterTest
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresUnknownEventHandlerId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var valuePropName = "testprop";
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var builder = new RenderTreeBuilder(renderer);
|
||||||
|
builder.OpenElement(0, "elem");
|
||||||
|
builder.AddAttribute(1, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.AddAttribute(2, valuePropName, "initial value");
|
||||||
|
builder.CloseElement();
|
||||||
|
var frames = builder.GetFrames();
|
||||||
|
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(builder, 456, "new value");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Collection(frames.AsEnumerable(),
|
||||||
|
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "initial value", 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresUpdatesToAttributesIfUnexpectedValueTypeSupplied()
|
||||||
|
{
|
||||||
|
// Currently we only allow the client to supply a string or a bool, since those are the
|
||||||
|
// only types of values we render onto attributes
|
||||||
|
|
||||||
|
// Arrange
|
||||||
|
var valuePropName = "testprop";
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var builder = new RenderTreeBuilder(renderer);
|
||||||
|
builder.OpenElement(0, "elem");
|
||||||
|
builder.AddAttribute(1, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.AddAttribute(2, valuePropName, "initial value");
|
||||||
|
builder.CloseElement();
|
||||||
|
var frames = builder.GetFrames();
|
||||||
|
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, new object());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Collection(frames.AsEnumerable(),
|
||||||
|
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "initial value", 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdatesOnlyMatchingAttributeValue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var valuePropName = "testprop";
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var builder = new RenderTreeBuilder(renderer);
|
||||||
|
builder.OpenElement(0, "elem");
|
||||||
|
builder.AddAttribute(1, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.AddAttribute(2, valuePropName, "unchanged 1");
|
||||||
|
builder.CloseElement();
|
||||||
|
builder.OpenElement(3, "elem");
|
||||||
|
builder.AddAttribute(4, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.AddAttribute(5, "unrelated prop before", "unchanged 2");
|
||||||
|
builder.AddAttribute(6, valuePropName, "initial value");
|
||||||
|
builder.AddAttribute(7, "unrelated prop after", "unchanged 3");
|
||||||
|
builder.CloseElement();
|
||||||
|
var frames = builder.GetFrames();
|
||||||
|
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||||
|
frames.Array[4] = frames.Array[4].WithAttributeEventHandlerId(456);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(builder, 456, "new value");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Collection(frames.AsEnumerable(),
|
||||||
|
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "unchanged 1", 2),
|
||||||
|
frame => AssertFrame.Element(frame, "elem", 5, 3),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 4),
|
||||||
|
frame => AssertFrame.Attribute(frame, "unrelated prop before", "unchanged 2", 5),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "new value", 6),
|
||||||
|
frame => AssertFrame.Attribute(frame, "unrelated prop after", "unchanged 3", 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddsAttributeIfNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var valuePropName = "testprop";
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var builder = new RenderTreeBuilder(renderer);
|
||||||
|
builder.OpenElement(0, "elem");
|
||||||
|
builder.AddAttribute(1, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.CloseElement();
|
||||||
|
var frames = builder.GetFrames();
|
||||||
|
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, "new value");
|
||||||
|
frames = builder.GetFrames();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Collection(frames.AsEnumerable(),
|
||||||
|
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "new value", RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpandsAllAncestorsWhenAddingAttribute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var valuePropName = "testprop";
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var builder = new RenderTreeBuilder(renderer);
|
||||||
|
builder.OpenElement(0, "grandparent");
|
||||||
|
builder.OpenRegion(1);
|
||||||
|
builder.OpenElement(2, "sibling before"); // To show that non-ancestors aren't expanded
|
||||||
|
builder.CloseElement();
|
||||||
|
builder.OpenElement(3, "elem with handler");
|
||||||
|
builder.AddAttribute(4, "eventname", () => { });
|
||||||
|
builder.SetUpdatesAttributeName(valuePropName);
|
||||||
|
builder.CloseElement(); // elem with handler
|
||||||
|
builder.CloseRegion();
|
||||||
|
builder.CloseElement(); // grandparent
|
||||||
|
var frames = builder.GetFrames();
|
||||||
|
frames.Array[4] = frames.Array[4].WithAttributeEventHandlerId(123);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, "new value");
|
||||||
|
frames = builder.GetFrames();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Collection(frames.AsEnumerable(),
|
||||||
|
frame => AssertFrame.Element(frame, "grandparent", 6, 0),
|
||||||
|
frame => AssertFrame.Region(frame, 5, 1),
|
||||||
|
frame => AssertFrame.Element(frame, "sibling before", 1, 2),
|
||||||
|
frame => AssertFrame.Element(frame, "elem with handler", 3, 3),
|
||||||
|
frame => AssertFrame.Attribute(frame, valuePropName, "new value", RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber),
|
||||||
|
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArrayRange<RenderTreeFrame> BuildFrames(params RenderTreeFrame[] frames)
|
||||||
|
=> new ArrayRange<RenderTreeFrame>(frames, frames.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3271,6 +3271,105 @@ namespace Microsoft.AspNetCore.Components.Test
|
||||||
Assert.Contains(exception2, renderer.HandledExceptions);
|
Assert.Contains(exception2, renderer.HandledExceptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)] // No existing attribute to update
|
||||||
|
[InlineData("old property value")] // Has existing attribute to update
|
||||||
|
public void EventFieldInfoCanPatchTreeSoDiffDoesNotUpdateAttribute(string oldValue)
|
||||||
|
{
|
||||||
|
// Arrange: Render a component with an event handler
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var component = new BoundPropertyComponent { BoundString = oldValue };
|
||||||
|
var componentId = renderer.AssignRootComponentId(component);
|
||||||
|
component.TriggerRender();
|
||||||
|
|
||||||
|
var eventHandlerId = renderer.Batches.Single()
|
||||||
|
.ReferenceFrames
|
||||||
|
.First(frame => frame.FrameType == RenderTreeFrameType.Attribute && frame.AttributeEventHandlerId > 0)
|
||||||
|
.AttributeEventHandlerId;
|
||||||
|
|
||||||
|
// Act: Fire event and re-render
|
||||||
|
var eventFieldInfo = new EventFieldInfo
|
||||||
|
{
|
||||||
|
FieldValue = "new property value",
|
||||||
|
ComponentId = componentId
|
||||||
|
};
|
||||||
|
var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, eventFieldInfo, new UIChangeEventArgs
|
||||||
|
{
|
||||||
|
Value = "new property value"
|
||||||
|
});
|
||||||
|
Assert.True(dispatchEventTask.IsCompletedSuccessfully);
|
||||||
|
|
||||||
|
// Assert: Property was updated, but the diff doesn't include changing the
|
||||||
|
// element attribute, since we told it the element attribute was already updated
|
||||||
|
Assert.Equal("new property value", component.BoundString);
|
||||||
|
Assert.Equal(2, renderer.Batches.Count);
|
||||||
|
var batch2 = renderer.Batches[1];
|
||||||
|
Assert.Collection(batch2.DiffsInOrder.Single().Edits.ToArray(), edit =>
|
||||||
|
{
|
||||||
|
// The only edit is updating the event handler ID, since the test component
|
||||||
|
// deliberately uses a capturing lambda. The whole point of this test is to
|
||||||
|
// show that the diff does *not* update the BoundString value attribute.
|
||||||
|
Assert.Equal(RenderTreeEditType.SetAttribute, edit.Type);
|
||||||
|
var attributeFrame = batch2.ReferenceFrames[edit.ReferenceFrameIndex];
|
||||||
|
AssertFrame.Attribute(attributeFrame, "ontestevent", typeof(Action<UIChangeEventArgs>));
|
||||||
|
Assert.NotEqual(0, attributeFrame.AttributeEventHandlerId);
|
||||||
|
Assert.NotEqual(eventHandlerId, attributeFrame.AttributeEventHandlerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EventFieldInfoWorksWhenEventHandlerIdWasSuperseded()
|
||||||
|
{
|
||||||
|
// Arrange: Render a component with an event handler
|
||||||
|
// We want the renderer to think none of the "UpdateDisplay" calls ever complete, because we
|
||||||
|
// want to keep reusing the same eventHandlerId and not let it get disposed
|
||||||
|
var renderCompletedTcs = new TaskCompletionSource<object>();
|
||||||
|
var renderer = new TestRenderer { NextRenderResultTask = renderCompletedTcs.Task };
|
||||||
|
var component = new BoundPropertyComponent { BoundString = "old property value" };
|
||||||
|
var componentId = renderer.AssignRootComponentId(component);
|
||||||
|
|
||||||
|
component.TriggerRender();
|
||||||
|
|
||||||
|
var eventHandlerId = renderer.Batches.Single()
|
||||||
|
.ReferenceFrames
|
||||||
|
.First(frame => frame.FrameType == RenderTreeFrameType.Attribute && frame.AttributeEventHandlerId > 0)
|
||||||
|
.AttributeEventHandlerId;
|
||||||
|
|
||||||
|
// Act: Fire event and re-render *repeatedly*, without changing to use a newer event handler ID,
|
||||||
|
// even though we know the event handler ID is getting updated in successive diffs
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var newPropertyValue = $"new property value {i}";
|
||||||
|
var fieldInfo = new EventFieldInfo
|
||||||
|
{
|
||||||
|
ComponentId = componentId,
|
||||||
|
FieldValue = newPropertyValue,
|
||||||
|
};
|
||||||
|
var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, fieldInfo, new UIChangeEventArgs
|
||||||
|
{
|
||||||
|
Value = newPropertyValue
|
||||||
|
});
|
||||||
|
Assert.True(dispatchEventTask.IsCompletedSuccessfully);
|
||||||
|
|
||||||
|
// Assert: Property was updated, but the diff doesn't include changing the
|
||||||
|
// element attribute, since we told it the element attribute was already updated
|
||||||
|
Assert.Equal(newPropertyValue, component.BoundString);
|
||||||
|
Assert.Equal(i + 2, renderer.Batches.Count);
|
||||||
|
var latestBatch = renderer.Batches.Last();
|
||||||
|
Assert.Collection(latestBatch.DiffsInOrder.Single().Edits.ToArray(), edit =>
|
||||||
|
{
|
||||||
|
// The only edit is updating the event handler ID, since the test component
|
||||||
|
// deliberately uses a capturing lambda. The whole point of this test is to
|
||||||
|
// show that the diff does *not* update the BoundString value attribute.
|
||||||
|
Assert.Equal(RenderTreeEditType.SetAttribute, edit.Type);
|
||||||
|
var attributeFrame = latestBatch.ReferenceFrames[edit.ReferenceFrameIndex];
|
||||||
|
AssertFrame.Attribute(attributeFrame, "ontestevent", typeof(Action<UIChangeEventArgs>));
|
||||||
|
Assert.NotEqual(0, attributeFrame.AttributeEventHandlerId);
|
||||||
|
Assert.NotEqual(eventHandlerId, attributeFrame.AttributeEventHandlerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class NoOpRenderer : Renderer
|
private class NoOpRenderer : Renderer
|
||||||
{
|
{
|
||||||
public NoOpRenderer() : base(new TestServiceProvider(), new RendererSynchronizationContext())
|
public NoOpRenderer() : base(new TestServiceProvider(), new RendererSynchronizationContext())
|
||||||
|
|
@ -3959,5 +4058,26 @@ namespace Microsoft.AspNetCore.Components.Test
|
||||||
builder.CloseElement();
|
builder.CloseElement();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BoundPropertyComponent : AutoRenderComponent
|
||||||
|
{
|
||||||
|
public string BoundString { get; set; }
|
||||||
|
|
||||||
|
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
|
{
|
||||||
|
var unrelatedThingToMakeTheLambdaCapture = new object();
|
||||||
|
|
||||||
|
builder.OpenElement(0, "element with event");
|
||||||
|
builder.AddAttribute(1, nameof(BoundString), BoundString);
|
||||||
|
builder.AddAttribute(2, "ontestevent", (UIChangeEventArgs eventArgs) =>
|
||||||
|
{
|
||||||
|
BoundString = (string)eventArgs.Value;
|
||||||
|
TriggerRender();
|
||||||
|
GC.KeepAlive(unrelatedThingToMakeTheLambdaCapture);
|
||||||
|
});
|
||||||
|
builder.SetUpdatesAttributeName(nameof(BoundString));
|
||||||
|
builder.CloseElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
|
||||||
|
|
||||||
public bool ShouldHandleExceptions { get; set; }
|
public bool ShouldHandleExceptions { get; set; }
|
||||||
|
|
||||||
|
public Task NextRenderResultTask { get; set; } = Task.CompletedTask;
|
||||||
|
|
||||||
public new int AssignRootComponentId(IComponent component)
|
public new int AssignRootComponentId(IComponent component)
|
||||||
=> base.AssignRootComponentId(component);
|
=> base.AssignRootComponentId(component);
|
||||||
|
|
||||||
|
|
@ -53,8 +55,11 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
|
||||||
public new Task RenderRootComponentAsync(int componentId, ParameterCollection parameters)
|
public new Task RenderRootComponentAsync(int componentId, ParameterCollection parameters)
|
||||||
=> InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters));
|
=> InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters));
|
||||||
|
|
||||||
public new Task DispatchEventAsync(int eventHandlerId, UIEventArgs args)
|
public Task DispatchEventAsync(int eventHandlerId, UIEventArgs args)
|
||||||
=> InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, args));
|
=> InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, null, args));
|
||||||
|
|
||||||
|
public new Task DispatchEventAsync(int eventHandlerId, EventFieldInfo eventFieldInfo, UIEventArgs args)
|
||||||
|
=> InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, eventFieldInfo, args));
|
||||||
|
|
||||||
private static Task UnwrapTask(Task task)
|
private static Task UnwrapTask(Task task)
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +114,7 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
|
||||||
// To test async UI updates, subclass TestRenderer and override UpdateDisplayAsync.
|
// To test async UI updates, subclass TestRenderer and override UpdateDisplayAsync.
|
||||||
|
|
||||||
OnUpdateDisplayComplete?.Invoke();
|
OnUpdateDisplayComplete?.Invoke();
|
||||||
return Task.CompletedTask;
|
return NextRenderResultTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,14 +224,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("-42", boundValue.Text);
|
Assert.Equal("-42", boundValue.Text);
|
||||||
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear target; value resets to zero
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("-42", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
target.SendKeys("42\t");
|
// Leading zeros are not preserved
|
||||||
Browser.Equal("42", () => boundValue.Text);
|
target.SendKeys("42");
|
||||||
|
Browser.Equal("042", () => target.GetAttribute("value"));
|
||||||
|
target.SendKeys("\t");
|
||||||
|
Browser.Equal("42", () => target.GetAttribute("value"));
|
||||||
|
Assert.Equal("42", boundValue.Text);
|
||||||
Assert.Equal("42", mirrorValue.GetAttribute("value"));
|
Assert.Equal("42", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,14 +283,17 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("3000000000", boundValue.Text);
|
Assert.Equal("3000000000", boundValue.Text);
|
||||||
Assert.Equal("3000000000", mirrorValue.GetAttribute("value"));
|
Assert.Equal("3000000000", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear target; value resets to zero
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("3000000000", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("3000000000", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("-3000000000\t");
|
target.SendKeys("-3000000000\t");
|
||||||
Browser.Equal("-3000000000", () => boundValue.Text);
|
Browser.Equal("-3000000000", () => target.GetAttribute("value"));
|
||||||
|
Assert.Equal("-3000000000", boundValue.Text);
|
||||||
Assert.Equal("-3000000000", mirrorValue.GetAttribute("value"));
|
Assert.Equal("-3000000000", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,14 +340,17 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("3.141", boundValue.Text);
|
Assert.Equal("3.141", boundValue.Text);
|
||||||
Assert.Equal("3.141", mirrorValue.GetAttribute("value"));
|
Assert.Equal("3.141", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear target; value resets to zero
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("3.141", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("3.141", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("-3.141\t");
|
target.SendKeys("-3.141\t");
|
||||||
Browser.Equal("-3.141", () => boundValue.Text);
|
Browser.Equal("-3.141", () => target.GetAttribute("value"));
|
||||||
|
Assert.Equal("-3.141", boundValue.Text);
|
||||||
Assert.Equal("-3.141", mirrorValue.GetAttribute("value"));
|
Assert.Equal("-3.141", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,12 +397,14 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("3.14159265359", boundValue.Text);
|
Assert.Equal("3.14159265359", boundValue.Text);
|
||||||
Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value"));
|
Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear target; value resets to default
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("3.14159265359", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("-3.14159265359\t");
|
target.SendKeys("-3.14159265359\t");
|
||||||
Browser.Equal("-3.14159265359", () => boundValue.Text);
|
Browser.Equal("-3.14159265359", () => boundValue.Text);
|
||||||
Assert.Equal("-3.14159265359", mirrorValue.GetAttribute("value"));
|
Assert.Equal("-3.14159265359", mirrorValue.GetAttribute("value"));
|
||||||
|
|
@ -399,8 +412,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
// Double shouldn't preserve trailing zeros
|
// Double shouldn't preserve trailing zeros
|
||||||
target.Clear();
|
target.Clear();
|
||||||
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("0.010\t");
|
target.SendKeys("0.010\t");
|
||||||
Browser.Equal("0.01", () => boundValue.Text);
|
Browser.Equal("0.01", () => target.GetAttribute("value"));
|
||||||
|
Assert.Equal("0.01", boundValue.Text);
|
||||||
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,10 +469,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("0.0000000000000000000000000001", boundValue.Text);
|
Assert.Equal("0.0000000000000000000000000001", boundValue.Text);
|
||||||
Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear textbox; value updates to zero because that's the default
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("0.0000000000000000000000000001", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
// Decimal should preserve trailing zeros
|
// Decimal should preserve trailing zeros
|
||||||
|
|
@ -518,18 +534,20 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Browser.Equal("0.01", () => boundValue.Text);
|
Browser.Equal("0.01", () => boundValue.Text);
|
||||||
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target to something invalid - the invalid value is preserved in the input, the other displays
|
// Modify target to something invalid - the invalid change is reverted
|
||||||
// don't change and still have the last value valid.
|
// back to the last valid value
|
||||||
target.SendKeys("A\t");
|
target.SendKeys("2A");
|
||||||
|
Assert.Equal("0.012A", target.GetAttribute("value"));
|
||||||
|
target.SendKeys("\t");
|
||||||
Browser.Equal("0.01", () => boundValue.Text);
|
Browser.Equal("0.01", () => boundValue.Text);
|
||||||
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
||||||
Assert.Equal("0.01A", target.GetAttribute("value"));
|
Assert.Equal("0.01", target.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target to something valid.
|
// Continue editing with valid inputs
|
||||||
target.SendKeys(Keys.Backspace);
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("1\t");
|
target.SendKeys("2\t");
|
||||||
Browser.Equal("0.011", () => boundValue.Text);
|
Browser.Equal("0.02", () => boundValue.Text);
|
||||||
Assert.Equal("0.011", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.02", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// This tests what happens you put invalid (unconvertable) input in. This is separate from the
|
// This tests what happens you put invalid (unconvertable) input in. This is separate from the
|
||||||
|
|
@ -550,18 +568,20 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Browser.Equal("0.01", () => boundValue.Text);
|
Browser.Equal("0.01", () => boundValue.Text);
|
||||||
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target to something invalid - the invalid value is preserved in the input, the other displays
|
// Modify target to something invalid - the invalid change is reverted
|
||||||
// don't change and still have the last value valid.
|
// back to the last valid value
|
||||||
target.SendKeys("A\t");
|
target.SendKeys("2A");
|
||||||
|
Assert.Equal("0.012A", target.GetAttribute("value"));
|
||||||
|
target.SendKeys("\t");
|
||||||
Browser.Equal("0.01", () => boundValue.Text);
|
Browser.Equal("0.01", () => boundValue.Text);
|
||||||
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
|
||||||
Assert.Equal("0.01A", target.GetAttribute("value"));
|
Assert.Equal("0.01", target.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target to something valid.
|
// Continue editing with valid inputs
|
||||||
target.SendKeys(Keys.Backspace);
|
target.SendKeys(Keys.Backspace);
|
||||||
target.SendKeys("1\t");
|
target.SendKeys("2\t");
|
||||||
Browser.Equal("0.011", () => boundValue.Text);
|
Browser.Equal("0.02", () => boundValue.Text);
|
||||||
Assert.Equal("0.011", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0.02", mirrorValue.GetAttribute("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -574,10 +594,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("-42", boundValue.Text);
|
Assert.Equal("-42", boundValue.Text);
|
||||||
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; value is not updated because it's not convertable.
|
// Clear target; value resets to zero
|
||||||
target.Clear();
|
target.Clear();
|
||||||
Browser.Equal("-42", () => boundValue.Text);
|
Browser.Equal("0", () => target.GetAttribute("value"));
|
||||||
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
|
Assert.Equal("0", boundValue.Text);
|
||||||
|
Assert.Equal("0", mirrorValue.GetAttribute("value"));
|
||||||
|
|
||||||
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
// Modify target; verify value is updated and that textboxes linked to the same data are updated
|
||||||
target.SendKeys("42\t");
|
target.SendKeys("42\t");
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,35 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Assert.Equal("unmatched-value", element.GetAttribute("unmatched"));
|
Assert.Equal("unmatched-value", element.GetAttribute("unmatched"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanPatchRenderTreeToMatchLatestDOMState()
|
||||||
|
{
|
||||||
|
var appElement = MountTestComponent<MovingCheckboxesComponent>();
|
||||||
|
var incompleteItemsSelector = By.CssSelector(".incomplete-items li");
|
||||||
|
var completeItemsSelector = By.CssSelector(".complete-items li");
|
||||||
|
WaitUntilExists(incompleteItemsSelector);
|
||||||
|
|
||||||
|
// Mark first item as done; observe the remaining incomplete item appears unchecked
|
||||||
|
// because the diff algoritm explicitly unchecks it
|
||||||
|
appElement.FindElement(By.CssSelector(".incomplete-items .item-isdone")).Click();
|
||||||
|
Browser.True(() =>
|
||||||
|
{
|
||||||
|
var incompleteLIs = appElement.FindElements(incompleteItemsSelector);
|
||||||
|
return incompleteLIs.Count == 1
|
||||||
|
&& !incompleteLIs[0].FindElement(By.CssSelector(".item-isdone")).Selected;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark first done item as not done; observe the remaining complete item appears checked
|
||||||
|
// because the diff algoritm explicitly re-checks it
|
||||||
|
appElement.FindElement(By.CssSelector(".complete-items .item-isdone")).Click();
|
||||||
|
Browser.True(() =>
|
||||||
|
{
|
||||||
|
var completeLIs = appElement.FindElements(completeItemsSelector);
|
||||||
|
return completeLIs.Count == 2
|
||||||
|
&& completeLIs[0].FindElement(By.CssSelector(".item-isdone")).Selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static IAlert SwitchToAlert(IWebDriver driver)
|
static IAlert SwitchToAlert(IWebDriver driver)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,29 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
Browser.Equal("abcdefghijklmnopqrstuvwxy", () => output.Text);
|
Browser.Equal("abcdefghijklmnopqrstuvwxy", () => output.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InputEvent_RespondsOnKeystrokes_EvenIfUpdatesAreLaggy()
|
||||||
|
{
|
||||||
|
// This test doesn't mean much on WebAssembly - it just shows that even if the CPU is locked
|
||||||
|
// up for a bit it doesn't cause typing to lose keystrokes. But when running server-side, this
|
||||||
|
// shows that network latency doesn't cause keystrokes to be lost even if:
|
||||||
|
// [1] By the time a keystroke event arrives, the event handler ID has since changed
|
||||||
|
// [2] We have the situation described under "the problem" at https://github.com/aspnet/AspNetCore/issues/8204#issuecomment-493986702
|
||||||
|
|
||||||
|
MountTestComponent<LaggyTypingComponent>();
|
||||||
|
|
||||||
|
var input = Browser.FindElement(By.TagName("input"));
|
||||||
|
var output = Browser.FindElement(By.Id("test-result"));
|
||||||
|
|
||||||
|
Browser.Equal(string.Empty, () => output.Text);
|
||||||
|
|
||||||
|
SendKeysSequentially(input, "abcdefg");
|
||||||
|
Browser.Equal("abcdefg", () => output.Text);
|
||||||
|
|
||||||
|
SendKeysSequentially(input, "hijklmn");
|
||||||
|
Browser.Equal("abcdefghijklmn", () => output.Text);
|
||||||
|
}
|
||||||
|
|
||||||
void SendKeysSequentially(IWebElement target, string text)
|
void SendKeysSequentially(IWebElement target, string text)
|
||||||
{
|
{
|
||||||
// Calling it for each character works around some chars being skipped
|
// Calling it for each character works around some chars being skipped
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
|
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
|
||||||
<option value="BasicTestApp.AuthTest.AuthRouter">Auth cases</option>
|
<option value="BasicTestApp.AuthTest.AuthRouter">Auth cases</option>
|
||||||
<option value="BasicTestApp.DuplicateAttributesComponent">Duplicate attributes</option>
|
<option value="BasicTestApp.DuplicateAttributesComponent">Duplicate attributes</option>
|
||||||
|
<option value="BasicTestApp.MovingCheckboxesComponent">Moving checkboxes diff case</option>
|
||||||
|
<option value="BasicTestApp.LaggyTypingComponent">Laggy typing</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@if (SelectedComponentType != null)
|
@if (SelectedComponentType != null)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
@using System.Threading
|
||||||
|
|
||||||
|
<input @bind-value=@InputText @bind-value:event="oninput" />
|
||||||
|
<p id="test-result">@InputText</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This component introduces lag during typing. It's not precisely the same as network latency,
|
||||||
|
but is similar enough that it surfaces what would be the same bugs if we didn't have mitigations.
|
||||||
|
That is:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Your keystrokes get queued up and by the time they are processed, they no longer match the event
|
||||||
|
handler ID in the render tree
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
By the time the render output is processed, the textbox contents have already been edited further.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The point of this test is to show that even in this hostile environment, we don't break the ability
|
||||||
|
to type normally, nor do we lose any of your typed characters.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@code {
|
||||||
|
string _inputText;
|
||||||
|
|
||||||
|
string InputText
|
||||||
|
{
|
||||||
|
get => _inputText;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Thread.Sleep(100);
|
||||||
|
_inputText = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<p>
|
||||||
|
This component represents a case that's difficult for the diff algorithm if it doesn't
|
||||||
|
understand how the underlying DOM gets mutated when you check a box.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If we didn't have the RenderTreeUpdater, then if you checked the first incomplete item,
|
||||||
|
the diff algoritm would see the subsequent render has only one "todo" item left, and would
|
||||||
|
match it with the existing 'li' element. Since that's still not done, the algorithm would
|
||||||
|
think no change was needed to the checkbox. But since you just clicked that checkbox, the
|
||||||
|
UI would show it as checked. It would look as if you have completed all four items instead
|
||||||
|
of just three.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
RenderTreeUpdater fixes this by patching the old render tree to match the latest state of
|
||||||
|
the DOM, so the diff algoritm sees it must explicitly uncheck the remaining 'todo' box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>To do</h2>
|
||||||
|
|
||||||
|
<ul class="incomplete-items">
|
||||||
|
@foreach (var item in items.Where(x => !x.IsDone))
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<input class="item-isdone" type="checkbox" @bind="@item.IsDone" />
|
||||||
|
<span class="item-text">@item.Text</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Done</h2>
|
||||||
|
|
||||||
|
<ul class="complete-items">
|
||||||
|
@foreach (var item in items.Where(x => x.IsDone))
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<input class="item-isdone" type="checkbox" @bind="@item.IsDone" />
|
||||||
|
<span class="item-text">@item.Text</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
List<TodoItem> items = new List<TodoItem>
|
||||||
|
{
|
||||||
|
new TodoItem { Text = "Alpha" },
|
||||||
|
new TodoItem { Text = "Beta" },
|
||||||
|
new TodoItem { Text = "Gamma", IsDone = true },
|
||||||
|
new TodoItem { Text = "Delta", IsDone = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
class TodoItem
|
||||||
|
{
|
||||||
|
public bool IsDone { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue