Browse Source

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

Steve Sanderson 6 years ago
parent
commit
f162ba1961
28 changed files with 921 additions and 86 deletions
  1. 1 1
      src/Components/Blazor/Blazor/ref/Microsoft.AspNetCore.Blazor.netstandard2.0.cs
  2. 7 5
      src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs
  3. 0 0
      src/Components/Browser.JS/dist/Release/blazor.server.js
  4. 0 0
      src/Components/Browser.JS/dist/Release/blazor.webassembly.js
  5. 19 17
      src/Components/Browser.JS/src/Rendering/BrowserRenderer.ts
  6. 12 5
      src/Components/Browser.JS/src/Rendering/EventDelegator.ts
  7. 34 0
      src/Components/Browser.JS/src/Rendering/EventFieldInfo.ts
  8. 1 0
      src/Components/Browser/ref/Microsoft.AspNetCore.Components.Browser.netstandard2.0.cs
  9. 35 1
      src/Components/Browser/src/RendererRegistryEventDispatcher.cs
  10. 7 1
      src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs
  11. 16 3
      src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs
  12. 19 0
      src/Components/Components/src/RenderTree/ArrayBuilder.cs
  13. 25 2
      src/Components/Components/src/RenderTree/RenderTreeBuilder.cs
  14. 27 3
      src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs
  15. 19 4
      src/Components/Components/src/RenderTree/RenderTreeFrame.cs
  16. 23 0
      src/Components/Components/src/Rendering/EventFieldInfo.cs
  17. 113 0
      src/Components/Components/src/Rendering/RenderTreeUpdater.cs
  18. 42 2
      src/Components/Components/src/Rendering/Renderer.cs
  19. 17 0
      src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs
  20. 167 0
      src/Components/Components/test/RenderTreeUpdaterTest.cs
  21. 120 0
      src/Components/Components/test/RendererTest.cs
  22. 8 3
      src/Components/Shared/test/TestRenderer.cs
  23. 60 39
      src/Components/test/E2ETest/Tests/BindTest.cs
  24. 29 0
      src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs
  25. 23 0
      src/Components/test/E2ETest/Tests/EventTest.cs
  26. 2 0
      src/Components/test/testassets/BasicTestApp/Index.razor
  27. 39 0
      src/Components/test/testassets/BasicTestApp/LaggyTypingComponent.razor
  28. 56 0
      src/Components/test/testassets/BasicTestApp/MovingCheckboxesComponent.razor

+ 1 - 1
src/Components/Blazor/Blazor/ref/Microsoft.AspNetCore.Blazor.netstandard2.0.cs

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

+ 7 - 5
src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs

@@ -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 it is too large
+ 0 - 0
src/Components/Browser.JS/dist/Release/blazor.server.js


File diff suppressed because it is too large
+ 0 - 0
src/Components/Browser.JS/dist/Release/blazor.webassembly.js


+ 19 - 17
src/Components/Browser.JS/src/Rendering/BrowserRenderer.ts

@@ -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(

+ 12 - 5
src/Components/Browser.JS/src/Rendering/EventDelegator.ts

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

+ 34 - 0
src/Components/Browser.JS/src/Rendering/EventFieldInfo.ts

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

+ 1 - 0
src/Components/Browser/ref/Microsoft.AspNetCore.Components.Browser.netstandard2.0.cs

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

+ 35 - 1
src/Components/Browser/src/RendererRegistryEventDispatcher.cs

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

+ 7 - 1
src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs

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

+ 16 - 3
src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs

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

+ 19 - 0
src/Components/Components/src/RenderTree/ArrayBuilder.cs

@@ -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.

+ 25 - 2
src/Components/Components/src/RenderTree/RenderTreeBuilder.cs

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

+ 27 - 3
src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs

@@ -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)
             {
+                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);
                 }
-                InitializeNewAttributeFrame(ref diffContext, ref newFrame);
-                var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame);
-                diffContext.Edits.Append(RenderTreeEdit.SetAttribute(diffContext.SiblingIndex, referenceFrameIndex));
             }
             else if (oldFrame.AttributeEventHandlerId > 0)
             {

+ 19 - 4
src/Components/Components/src/RenderTree/RenderTreeFrame.cs

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

+ 23 - 0
src/Components/Components/src/Rendering/EventFieldInfo.cs

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

+ 113 - 0
src/Components/Components/src/Rendering/RenderTreeUpdater.cs

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

+ 42 - 2
src/Components/Components/src/Rendering/Renderer.cs

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

+ 17 - 0
src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs

@@ -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()
         {

+ 167 - 0
src/Components/Components/test/RenderTreeUpdaterTest.cs

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

+ 120 - 0
src/Components/Components/test/RendererTest.cs

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

+ 8 - 3
src/Components/Shared/test/TestRenderer.cs

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

+ 60 - 39
src/Components/test/E2ETest/Tests/BindTest.cs

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

+ 29 - 0
src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs

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

+ 23 - 0
src/Components/test/E2ETest/Tests/EventTest.cs

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

+ 2 - 0
src/Components/test/testassets/BasicTestApp/Index.razor

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

+ 39 - 0
src/Components/test/testassets/BasicTestApp/LaggyTypingComponent.razor

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

+ 56 - 0
src/Components/test/testassets/BasicTestApp/MovingCheckboxesComponent.razor

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

Some files were not shown because too many files changed in this diff