For two-way bindings, enforce consistency between .NET model and DOM by patching old tree. Fixes #8204 (#11438)

This commit is contained in:
Steve Sanderson 2019-06-24 23:15:33 +01:00 committed by GitHub
parent 151ae52661
commit f162ba1961
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 926 additions and 91 deletions

View File

@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
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<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 HandleException(System.Exception exception) { }
protected override System.Threading.Tasks.Task UpdateDisplayAsync(in Microsoft.AspNetCore.Components.Rendering.RenderBatch batch) { throw null; }

View File

@ -116,7 +116,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
}
/// <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
// simultaneously anyway (there's only one thread), they could run nested on
@ -135,7 +135,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
if (isDispatchingEvent)
{
var info = new IncomingEventInfo(eventHandlerId, eventArgs);
var info = new IncomingEventInfo(eventHandlerId, eventFieldInfo, eventArgs);
deferredIncomingEvents.Enqueue(info);
return info.TaskCompletionSource.Task;
}
@ -144,7 +144,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
try
{
isDispatchingEvent = true;
return base.DispatchEventAsync(eventHandlerId, eventArgs);
return base.DispatchEventAsync(eventHandlerId, eventFieldInfo, eventArgs);
}
finally
{
@ -168,7 +168,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
try
{
await DispatchEventAsync(info.EventHandlerId, info.EventArgs);
await DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs);
taskCompletionSource.SetResult(null);
}
catch (Exception ex)
@ -180,12 +180,14 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
readonly struct IncomingEventInfo
{
public readonly int EventHandlerId;
public readonly EventFieldInfo EventFieldInfo;
public readonly UIEventArgs EventArgs;
public readonly TaskCompletionSource<object> TaskCompletionSource;
public IncomingEventInfo(int eventHandlerId, UIEventArgs eventArgs)
public IncomingEventInfo(int eventHandlerId, EventFieldInfo eventFieldInfo, UIEventArgs eventArgs)
{
EventHandlerId = eventHandlerId;
EventFieldInfo = eventFieldInfo;
EventArgs = eventArgs;
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

View File

@ -3,6 +3,7 @@ import { EventDelegator } from './EventDelegator';
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
import { applyCaptureIdToElement } from './ElementReferenceCapture';
import { EventFieldInfo } from './EventFieldInfo';
const selectValuePropname = '_blazorSelectValue';
const sharedTemplateElemForParsing = document.createElement('template');
const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g');
@ -18,8 +19,8 @@ export class BrowserRenderer {
public constructor(browserRendererId: number) {
this.browserRendererId = browserRendererId;
this.eventDelegator = new EventDelegator((event, eventHandlerId, eventArgs) => {
raiseEvent(event, this.browserRendererId, eventHandlerId, eventArgs);
this.eventDelegator = new EventDelegator((event, eventHandlerId, eventArgs, eventFieldInfo) => {
raiseEvent(event, this.browserRendererId, eventHandlerId, eventArgs, eventFieldInfo);
});
}
@ -50,7 +51,7 @@ export class BrowserRenderer {
const ownerDocument = getClosestDomElement(element).ownerDocument;
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
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
@ -70,7 +71,7 @@ export class BrowserRenderer {
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 childIndexAtCurrentDepth = childIndex;
let permutationList: PermutationListEntry[] | undefined;
@ -91,7 +92,7 @@ export class BrowserRenderer {
const frameIndex = editReader.newTreeIndex(edit);
const frame = batch.referenceFramesEntry(referenceFrames, frameIndex);
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;
}
case EditType.removeFrame: {
@ -105,7 +106,7 @@ export class BrowserRenderer {
const siblingIndex = editReader.siblingIndex(edit);
const element = getLogicalChild(parent, childIndexAtCurrentDepth + siblingIndex);
if (element instanceof Element) {
this.applyAttribute(batch, element, frame);
this.applyAttribute(batch, componentId, element, frame);
} else {
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 frameType = frameReader.frameType(frame);
switch (frameType) {
case FrameType.element:
this.insertElement(batch, parent, childIndex, frames, frame, frameIndex);
this.insertElement(batch, componentId, parent, childIndex, frames, frame, frameIndex);
return 1;
case FrameType.text:
this.insertText(batch, parent, childIndex, frame);
@ -198,7 +199,7 @@ export class BrowserRenderer {
this.insertComponent(batch, parent, childIndex, frame);
return 1;
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:
if (parent instanceof Element) {
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 tagName = frameReader.elementName(frame)!;
const newDomElementRaw = tagName === 'svg' || isSvgElement(parent) ?
@ -229,11 +230,11 @@ export class BrowserRenderer {
for (let descendantIndex = frameIndex + 1; descendantIndex < descendantsEndIndexExcl; descendantIndex++) {
const descendantFrame = batch.referenceFramesEntry(frames, descendantIndex);
if (frameReader.frameType(descendantFrame) === FrameType.attribute) {
this.applyAttribute(batch, newDomElementRaw, descendantFrame);
this.applyAttribute(batch, componentId, newDomElementRaw, descendantFrame);
} else {
// 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
this.insertFrameRange(batch, newElement, 0, frames, descendantIndex, descendantsEndIndexExcl);
this.insertFrameRange(batch, componentId, newElement, 0, frames, descendantIndex, descendantsEndIndexExcl);
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 attributeName = frameReader.attributeName(attributeFrame)!;
const browserRendererId = this.browserRendererId;
@ -277,7 +278,7 @@ export class BrowserRenderer {
if (firstTwoChars !== 'on' || !eventName) {
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;
}
@ -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;
for (let index = startIndex; index < endIndexExcl; 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;
// 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]) {
event.preventDefault();
}
@ -406,6 +407,7 @@ function raiseEvent(event: Event, browserRendererId: number, eventHandlerId: num
browserRendererId,
eventHandlerId,
eventArgsType: eventArgs.type,
eventFieldInfo: eventFieldInfo,
};
return DotNet.invokeMethodAsync(

View File

@ -1,4 +1,5 @@
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
import { EventFieldInfo } from './EventFieldInfo';
const nonBubblingEvents = toLookup([
'abort',
@ -21,7 +22,7 @@ const nonBubblingEvents = toLookup([
]);
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
@ -40,7 +41,7 @@ export class EventDelegator {
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
let infoForElement: EventHandlerInfosForElement = element[this.eventsCollectionKey];
if (!infoForElement) {
@ -53,7 +54,7 @@ export class EventDelegator {
this.eventInfoStore.update(oldInfo.eventHandlerId, eventHandlerId);
} else {
// 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);
infoForElement[eventName] = newInfo;
}
@ -89,7 +90,7 @@ export class EventDelegator {
const eventIsNonBubbling = nonBubblingEvents.hasOwnProperty(evt.type);
while (candidateElement) {
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
const handlerInfos = candidateElement[this.eventsCollectionKey];
const handlerInfos: EventHandlerInfosForElement = candidateElement[this.eventsCollectionKey];
if (handlerInfos.hasOwnProperty(evt.type)) {
// We are going to raise an event for this element, so prepare info needed by the .NET code
if (!eventArgs) {
@ -97,7 +98,8 @@ export class EventDelegator {
}
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;
eventName: string;
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 } {

View File

@ -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;
}

View File

@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Components.Browser
public BrowserEventDescriptor() { }
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 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 { } }
}
}

View File

@ -21,9 +21,38 @@ namespace Microsoft.AspNetCore.Components.Browser
public static Task DispatchEvent(
BrowserEventDescriptor eventDescriptor, string eventArgsJson)
{
InterpretEventDescriptor(eventDescriptor);
var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson);
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)
@ -105,6 +134,11 @@ namespace Microsoft.AspNetCore.Components.Browser
/// For framework use only.
/// </summary>
public string EventArgsType { get; set; }
/// <summary>
/// For framework use only.
/// </summary>
public EventFieldInfo EventFieldInfo { get; set; }
}
}
}

View File

@ -688,6 +688,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
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 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 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 int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { 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() { }
protected virtual void Dispose(bool disposing) { }
protected abstract void HandleException(System.Exception exception);

View File

@ -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
// to process invalid input but avoid dirtying the state of the component if can't be converted. Imagine if
// we assigned default(T) on failure - this would result in trouncing the user's typed in value.
// We only invoke the setter if the conversion didn't throw, or if the newly-entered value is empty.
// If the user entered some non-empty value we couldn't parse, we leave the state of the .NET field
// 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)
{
setter(value);
}
else if (string.Empty.Equals(e.Value))
{
setter(default);
}
};
return factory.Create<UIChangeEventArgs>(receiver, callback);
}

View File

@ -106,6 +106,25 @@ namespace Microsoft.AspNetCore.Components.RenderTree
_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>
/// Marks the array as empty, also shrinking the underlying storage if it was
/// not being used to near its full capacity.

View File

@ -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>
public void SetUpdatesAttributeName(string updatesAttributeName)
{
// TODO: This will be implemented in a later PR, once aspnetcore-tooling
// is updated to call this method.
if (_entries.Count == 0)
{
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>
@ -699,6 +709,19 @@ namespace Microsoft.AspNetCore.Components.RenderTree
_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>
/// Returns the <see cref="RenderTreeFrame"/> values that have been appended.
/// </summary>

View File

@ -11,6 +11,11 @@ namespace Microsoft.AspNetCore.Components.RenderTree
{
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(
Renderer renderer,
RenderBatchBuilder batchBuilder,
@ -382,6 +387,21 @@ namespace Microsoft.AspNetCore.Components.RenderTree
}
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.
RemoveOldFrame(ref diffContext, oldStartIndex);
@ -661,13 +681,17 @@ namespace Microsoft.AspNetCore.Components.RenderTree
var valueChanged = !Equals(oldFrame.AttributeValue, newFrame.AttributeValue);
if (valueChanged)
{
if (oldFrame.AttributeEventHandlerId > 0)
{
diffContext.BatchBuilder.DisposedEventHandlerIds.Append(oldFrame.AttributeEventHandlerId);
}
InitializeNewAttributeFrame(ref diffContext, ref newFrame);
var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame);
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)
{

View File

@ -104,6 +104,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
/// </summary>
[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
// --------------------------------------------------------------------------------
@ -259,7 +267,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
}
// 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()
{
FrameType = RenderTreeFrameType.Attribute;
@ -267,6 +275,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
AttributeName = attributeName;
AttributeValue = attributeValue;
AttributeEventHandlerId = attributeEventHandlerId;
AttributeEventUpdatesAttributeName = attributeEventUpdatesAttributeName;
}
// Element reference capture constructor
@ -299,7 +308,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
=> new RenderTreeFrame(sequence, isMarkup: true, textOrMarkup: markupContent);
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)
=> 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);
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)
=> new RenderTreeFrame(Sequence, componentSubtreeLength: ComponentSubtreeLength, ComponentType, componentState, ComponentKey);
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)
=> new RenderTreeFrame(Sequence, regionSubtreeLength: regionSubtreeLength);

View File

@ -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; }
}
}

View File

@ -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;
}
}
}
}
}
}

View File

@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
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 int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
@ -205,11 +206,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
/// </summary>
/// <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="fieldInfo">Information that the renderer can use to update the state of the existing render tree to match the UI.</param>
/// <returns>
/// A <see cref="Task"/> which will complete once all asynchronous processing related to the event
/// has completed.
/// </returns>
public virtual Task DispatchEventAsync(int eventHandlerId, UIEventArgs eventArgs)
public virtual Task DispatchEventAsync(int eventHandlerId, EventFieldInfo fieldInfo, UIEventArgs eventArgs)
{
EnsureSynchronizationContext();
@ -218,6 +220,12 @@ namespace Microsoft.AspNetCore.Components.Rendering
throw new ArgumentException($"There is no event handler with ID {eventHandlerId}");
}
if (fieldInfo != null)
{
var latestEquivalentEventHandlerId = FindLatestEventHandlerIdInChain(eventHandlerId);
UpdateRenderTreeToMatchClientState(latestEquivalentEventHandlerId, fieldInfo);
}
Task task = null;
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()
{
// When the IDispatcher is a synchronization context
@ -613,7 +639,9 @@ namespace Microsoft.AspNetCore.Components.Rendering
var count = eventHandlerIds.Count;
for (var i = 0; i < count; i++)
{
_eventBindings.Remove(array[i]);
var eventHandlerIdToRemove = array[i];
_eventBindings.Remove(eventHandlerIdToRemove);
_eventHandlerIdReplacements.Remove(eventHandlerIdToRemove);
}
}
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>
/// Releases all resources currently used by this <see cref="Renderer"/> instance.
/// </summary>

View File

@ -28,6 +28,23 @@ namespace Microsoft.AspNetCore.Components
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]
public async Task CreateBinder_ThrowsSetterException()
{

View File

@ -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);
}
}

View File

@ -3271,6 +3271,105 @@ namespace Microsoft.AspNetCore.Components.Test
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
{
public NoOpRenderer() : base(new TestServiceProvider(), new RendererSynchronizationContext())
@ -3959,5 +4058,26 @@ namespace Microsoft.AspNetCore.Components.Test
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();
}
}
}
}

View File

@ -38,6 +38,8 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
public bool ShouldHandleExceptions { get; set; }
public Task NextRenderResultTask { get; set; } = Task.CompletedTask;
public new int AssignRootComponentId(IComponent component)
=> base.AssignRootComponentId(component);
@ -53,8 +55,11 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
public new Task RenderRootComponentAsync(int componentId, ParameterCollection parameters)
=> InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters));
public new Task DispatchEventAsync(int eventHandlerId, UIEventArgs args)
=> InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, args));
public Task DispatchEventAsync(int eventHandlerId, UIEventArgs 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)
{
@ -109,7 +114,7 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
// To test async UI updates, subclass TestRenderer and override UpdateDisplayAsync.
OnUpdateDisplayComplete?.Invoke();
return Task.CompletedTask;
return NextRenderResultTask;
}
}
}

View File

@ -224,14 +224,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("-42", boundValue.Text);
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();
Browser.Equal("-42", () => boundValue.Text);
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
target.SendKeys("42\t");
Browser.Equal("42", () => boundValue.Text);
// Leading zeros are not preserved
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"));
}
@ -278,14 +283,17 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("3000000000", boundValue.Text);
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();
Browser.Equal("3000000000", () => boundValue.Text);
Assert.Equal("3000000000", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
target.SendKeys(Keys.Backspace);
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"));
}
@ -332,14 +340,17 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("3.141", boundValue.Text);
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();
Browser.Equal("3.141", () => boundValue.Text);
Assert.Equal("3.141", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
target.SendKeys(Keys.Backspace);
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"));
}
@ -386,12 +397,14 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("3.14159265359", boundValue.Text);
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();
Browser.Equal("3.14159265359", () => boundValue.Text);
Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
target.SendKeys(Keys.Backspace);
target.SendKeys("-3.14159265359\t");
Browser.Equal("-3.14159265359", () => boundValue.Text);
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
// Double shouldn't preserve trailing zeros
target.Clear();
target.SendKeys(Keys.Backspace);
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"));
}
@ -454,10 +469,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("0.0000000000000000000000000001", boundValue.Text);
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();
Browser.Equal("0.0000000000000000000000000001", () => boundValue.Text);
Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
// Decimal should preserve trailing zeros
@ -518,18 +534,20 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Browser.Equal("0.01", () => boundValue.Text);
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
// Modify target to something invalid - the invalid value is preserved in the input, the other displays
// don't change and still have the last value valid.
target.SendKeys("A\t");
// Modify target to something invalid - the invalid change is reverted
// back to the last valid value
target.SendKeys("2A");
Assert.Equal("0.012A", target.GetAttribute("value"));
target.SendKeys("\t");
Browser.Equal("0.01", () => boundValue.Text);
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("1\t");
Browser.Equal("0.011", () => boundValue.Text);
Assert.Equal("0.011", mirrorValue.GetAttribute("value"));
target.SendKeys("2\t");
Browser.Equal("0.02", () => boundValue.Text);
Assert.Equal("0.02", mirrorValue.GetAttribute("value"));
}
// 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);
Assert.Equal("0.01", mirrorValue.GetAttribute("value"));
// Modify target to something invalid - the invalid value is preserved in the input, the other displays
// don't change and still have the last value valid.
target.SendKeys("A\t");
// Modify target to something invalid - the invalid change is reverted
// back to the last valid value
target.SendKeys("2A");
Assert.Equal("0.012A", target.GetAttribute("value"));
target.SendKeys("\t");
Browser.Equal("0.01", () => boundValue.Text);
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("1\t");
Browser.Equal("0.011", () => boundValue.Text);
Assert.Equal("0.011", mirrorValue.GetAttribute("value"));
target.SendKeys("2\t");
Browser.Equal("0.02", () => boundValue.Text);
Assert.Equal("0.02", mirrorValue.GetAttribute("value"));
}
[Fact]
@ -574,10 +594,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Assert.Equal("-42", boundValue.Text);
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();
Browser.Equal("-42", () => boundValue.Text);
Assert.Equal("-42", mirrorValue.GetAttribute("value"));
Browser.Equal("0", () => target.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
target.SendKeys("42\t");

View File

@ -617,6 +617,35 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
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)
{
try

View File

@ -185,6 +185,29 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
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)
{
// Calling it for each character works around some chars being skipped

View File

@ -57,6 +57,8 @@
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
<option value="BasicTestApp.AuthTest.AuthRouter">Auth cases</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>
@if (SelectedComponentType != null)

View File

@ -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;
}
}
}

View File

@ -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; }
}
}