For two-way bindings, enforce consistency between .NET model and DOM by patching old tree. Fixes #8204 (#11438)
This commit is contained in:
parent
151ae52661
commit
f162ba1961
|
|
@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
|||
public WebAssemblyRenderer(System.IServiceProvider serviceProvider) : base (default(System.IServiceProvider)) { }
|
||||
public 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; }
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 } {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
export class EventFieldInfo {
|
||||
constructor(public componentId: number, public fieldValue: string | boolean) {
|
||||
}
|
||||
|
||||
public static fromEvent(componentId: number, event: Event): EventFieldInfo | null {
|
||||
const elem = event.target;
|
||||
if (elem instanceof Element) {
|
||||
const fieldData = getFormFieldData(elem);
|
||||
if (fieldData) {
|
||||
return new EventFieldInfo(componentId, fieldData.value);
|
||||
}
|
||||
}
|
||||
|
||||
// This event isn't happening on a form field that we can reverse-map back to some incoming attribute
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFormFieldData(elem: Element) {
|
||||
// The logic in here should be the inverse of the logic in BrowserRenderer's tryApplySpecialProperty.
|
||||
// That is, we're doing the reverse mapping, starting from an HTML property and reconstructing which
|
||||
// "special" attribute would have been mapped to that property.
|
||||
if (elem instanceof HTMLInputElement) {
|
||||
return (elem.type && elem.type.toLowerCase() === 'checkbox')
|
||||
? { value: elem.checked }
|
||||
: { value: elem.value };
|
||||
}
|
||||
|
||||
if (elem instanceof HTMLSelectElement || elem instanceof HTMLTextAreaElement) {
|
||||
return { value: elem.value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Components.Browser
|
|||
public BrowserEventDescriptor() { }
|
||||
public 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 { } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Rendering
|
||||
{
|
||||
/// <summary>
|
||||
/// Information supplied with an event notification that can be used to update an existing
|
||||
/// render tree to match the latest UI state when a form field has mutated. To determine
|
||||
/// which field has been mutated, the renderer matches it based on the event handler ID.
|
||||
/// </summary>
|
||||
public class EventFieldInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the component whose render tree contains the affected form field.
|
||||
/// </summary>
|
||||
public int ComponentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the form field's new value.
|
||||
/// </summary>
|
||||
public object FieldValue { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using Microsoft.AspNetCore.Components.RenderTree;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Rendering
|
||||
{
|
||||
internal class RenderTreeUpdater
|
||||
{
|
||||
public static void UpdateToMatchClientState(RenderTreeBuilder renderTreeBuilder, int eventHandlerId, object newFieldValue)
|
||||
{
|
||||
// We only allow the client to supply string or bool currently, since those are the only kinds of
|
||||
// values we output on attributes that go to the client
|
||||
if (!(newFieldValue is string || newFieldValue is bool))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the element that contains the event handler
|
||||
var frames = renderTreeBuilder.GetFrames();
|
||||
var framesArray = frames.Array;
|
||||
var framesLength = frames.Count;
|
||||
var closestElementFrameIndex = -1;
|
||||
for (var frameIndex = 0; frameIndex < framesLength; frameIndex++)
|
||||
{
|
||||
ref var frame = ref framesArray[frameIndex];
|
||||
switch (frame.FrameType)
|
||||
{
|
||||
case RenderTreeFrameType.Element:
|
||||
closestElementFrameIndex = frameIndex;
|
||||
break;
|
||||
case RenderTreeFrameType.Attribute:
|
||||
if (frame.AttributeEventHandlerId == eventHandlerId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(frame.AttributeEventUpdatesAttributeName))
|
||||
{
|
||||
UpdateFrameToMatchClientState(
|
||||
renderTreeBuilder,
|
||||
framesArray,
|
||||
closestElementFrameIndex,
|
||||
frame.AttributeEventUpdatesAttributeName,
|
||||
newFieldValue);
|
||||
}
|
||||
|
||||
// Whether or not we did update the frame, that was the one that matches
|
||||
// the event handler ID, so no need to look any further
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateFrameToMatchClientState(RenderTreeBuilder renderTreeBuilder, RenderTreeFrame[] framesArray, int elementFrameIndex, string attributeName, object attributeValue)
|
||||
{
|
||||
// Find the attribute frame
|
||||
ref var elementFrame = ref framesArray[elementFrameIndex];
|
||||
var elementSubtreeEndIndexExcl = elementFrameIndex + elementFrame.ElementSubtreeLength;
|
||||
for (var attributeFrameIndex = elementFrameIndex + 1; attributeFrameIndex < elementSubtreeEndIndexExcl; attributeFrameIndex++)
|
||||
{
|
||||
ref var attributeFrame = ref framesArray[attributeFrameIndex];
|
||||
if (attributeFrame.FrameType != RenderTreeFrameType.Attribute)
|
||||
{
|
||||
// We're now looking at the descendants not attributes, so the search is over
|
||||
break;
|
||||
}
|
||||
|
||||
if (attributeFrame.AttributeName == attributeName)
|
||||
{
|
||||
// Found an existing attribute we can update
|
||||
attributeFrame = attributeFrame.WithAttributeValue(attributeValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we didn't find the desired attribute, so we have to insert a new frame for it
|
||||
var insertAtIndex = elementFrameIndex + 1;
|
||||
renderTreeBuilder.InsertAttributeExpensive(insertAtIndex, RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber, attributeName, attributeValue);
|
||||
framesArray = renderTreeBuilder.GetFrames().Array; // Refresh in case it mutated due to the expansion
|
||||
|
||||
// Update subtree length for this and all ancestor containers
|
||||
// Ancestors can only be regions or other elements, since components can't "contain" elements inline
|
||||
// We only have to walk backwards, since later entries in the frames array can't contain an earlier one
|
||||
for (var otherFrameIndex = elementFrameIndex; otherFrameIndex >= 0; otherFrameIndex--)
|
||||
{
|
||||
ref var otherFrame = ref framesArray[otherFrameIndex];
|
||||
switch (otherFrame.FrameType)
|
||||
{
|
||||
case RenderTreeFrameType.Element:
|
||||
{
|
||||
var otherFrameSubtreeLength = otherFrame.ElementSubtreeLength;
|
||||
var otherFrameEndIndexExcl = otherFrameIndex + otherFrameSubtreeLength;
|
||||
if (otherFrameEndIndexExcl > elementFrameIndex) // i.e., contains the element we're inserting into
|
||||
{
|
||||
otherFrame = otherFrame.WithElementSubtreeLength(otherFrameSubtreeLength + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RenderTreeFrameType.Region:
|
||||
{
|
||||
var otherFrameSubtreeLength = otherFrame.RegionSubtreeLength;
|
||||
var otherFrameEndIndexExcl = otherFrameIndex + otherFrameSubtreeLength;
|
||||
if (otherFrameEndIndexExcl > elementFrameIndex) // i.e., contains the element we're inserting into
|
||||
{
|
||||
otherFrame = otherFrame.WithRegionSubtreeLength(otherFrameSubtreeLength + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
|||
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
|
||||
private readonly 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.Components.RenderTree;
|
||||
using Microsoft.AspNetCore.Components.Test.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Test
|
||||
{
|
||||
public class RenderTreeUpdaterTest
|
||||
{
|
||||
[Fact]
|
||||
public void IgnoresUnknownEventHandlerId()
|
||||
{
|
||||
// Arrange
|
||||
var valuePropName = "testprop";
|
||||
var renderer = new TestRenderer();
|
||||
var builder = new RenderTreeBuilder(renderer);
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.AddAttribute(1, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.AddAttribute(2, valuePropName, "initial value");
|
||||
builder.CloseElement();
|
||||
var frames = builder.GetFrames();
|
||||
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||
|
||||
// Act
|
||||
RenderTreeUpdater.UpdateToMatchClientState(builder, 456, "new value");
|
||||
|
||||
// Assert
|
||||
Assert.Collection(frames.AsEnumerable(),
|
||||
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "initial value", 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresUpdatesToAttributesIfUnexpectedValueTypeSupplied()
|
||||
{
|
||||
// Currently we only allow the client to supply a string or a bool, since those are the
|
||||
// only types of values we render onto attributes
|
||||
|
||||
// Arrange
|
||||
var valuePropName = "testprop";
|
||||
var renderer = new TestRenderer();
|
||||
var builder = new RenderTreeBuilder(renderer);
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.AddAttribute(1, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.AddAttribute(2, valuePropName, "initial value");
|
||||
builder.CloseElement();
|
||||
var frames = builder.GetFrames();
|
||||
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||
|
||||
// Act
|
||||
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, new object());
|
||||
|
||||
// Assert
|
||||
Assert.Collection(frames.AsEnumerable(),
|
||||
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "initial value", 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatesOnlyMatchingAttributeValue()
|
||||
{
|
||||
// Arrange
|
||||
var valuePropName = "testprop";
|
||||
var renderer = new TestRenderer();
|
||||
var builder = new RenderTreeBuilder(renderer);
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.AddAttribute(1, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.AddAttribute(2, valuePropName, "unchanged 1");
|
||||
builder.CloseElement();
|
||||
builder.OpenElement(3, "elem");
|
||||
builder.AddAttribute(4, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.AddAttribute(5, "unrelated prop before", "unchanged 2");
|
||||
builder.AddAttribute(6, valuePropName, "initial value");
|
||||
builder.AddAttribute(7, "unrelated prop after", "unchanged 3");
|
||||
builder.CloseElement();
|
||||
var frames = builder.GetFrames();
|
||||
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123); // An unrelated event
|
||||
frames.Array[4] = frames.Array[4].WithAttributeEventHandlerId(456);
|
||||
|
||||
// Act
|
||||
RenderTreeUpdater.UpdateToMatchClientState(builder, 456, "new value");
|
||||
|
||||
// Assert
|
||||
Assert.Collection(frames.AsEnumerable(),
|
||||
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "unchanged 1", 2),
|
||||
frame => AssertFrame.Element(frame, "elem", 5, 3),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 4),
|
||||
frame => AssertFrame.Attribute(frame, "unrelated prop before", "unchanged 2", 5),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "new value", 6),
|
||||
frame => AssertFrame.Attribute(frame, "unrelated prop after", "unchanged 3", 7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddsAttributeIfNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var valuePropName = "testprop";
|
||||
var renderer = new TestRenderer();
|
||||
var builder = new RenderTreeBuilder(renderer);
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.AddAttribute(1, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.CloseElement();
|
||||
var frames = builder.GetFrames();
|
||||
frames.Array[1] = frames.Array[1].WithAttributeEventHandlerId(123);
|
||||
|
||||
// Act
|
||||
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, "new value");
|
||||
frames = builder.GetFrames();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(frames.AsEnumerable(),
|
||||
frame => AssertFrame.Element(frame, "elem", 3, 0),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "new value", RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandsAllAncestorsWhenAddingAttribute()
|
||||
{
|
||||
// Arrange
|
||||
var valuePropName = "testprop";
|
||||
var renderer = new TestRenderer();
|
||||
var builder = new RenderTreeBuilder(renderer);
|
||||
builder.OpenElement(0, "grandparent");
|
||||
builder.OpenRegion(1);
|
||||
builder.OpenElement(2, "sibling before"); // To show that non-ancestors aren't expanded
|
||||
builder.CloseElement();
|
||||
builder.OpenElement(3, "elem with handler");
|
||||
builder.AddAttribute(4, "eventname", () => { });
|
||||
builder.SetUpdatesAttributeName(valuePropName);
|
||||
builder.CloseElement(); // elem with handler
|
||||
builder.CloseRegion();
|
||||
builder.CloseElement(); // grandparent
|
||||
var frames = builder.GetFrames();
|
||||
frames.Array[4] = frames.Array[4].WithAttributeEventHandlerId(123);
|
||||
|
||||
// Act
|
||||
RenderTreeUpdater.UpdateToMatchClientState(builder, 123, "new value");
|
||||
frames = builder.GetFrames();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(frames.AsEnumerable(),
|
||||
frame => AssertFrame.Element(frame, "grandparent", 6, 0),
|
||||
frame => AssertFrame.Region(frame, 5, 1),
|
||||
frame => AssertFrame.Element(frame, "sibling before", 1, 2),
|
||||
frame => AssertFrame.Element(frame, "elem with handler", 3, 3),
|
||||
frame => AssertFrame.Attribute(frame, valuePropName, "new value", RenderTreeDiffBuilder.SystemAddedAttributeSequenceNumber),
|
||||
frame => AssertFrame.Attribute(frame, "eventname", v => Assert.IsType<Action>(v), 4));
|
||||
}
|
||||
|
||||
private static ArrayRange<RenderTreeFrame> BuildFrames(params RenderTreeFrame[] frames)
|
||||
=> new ArrayRange<RenderTreeFrame>(frames, frames.Length);
|
||||
}
|
||||
}
|
||||
|
|
@ -3271,6 +3271,105 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
Assert.Contains(exception2, renderer.HandledExceptions);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
@using System.Threading
|
||||
|
||||
<input @bind-value=@InputText @bind-value:event="oninput" />
|
||||
<p id="test-result">@InputText</p>
|
||||
|
||||
<p>
|
||||
This component introduces lag during typing. It's not precisely the same as network latency,
|
||||
but is similar enough that it surfaces what would be the same bugs if we didn't have mitigations.
|
||||
That is:
|
||||
<ul>
|
||||
<li>
|
||||
Your keystrokes get queued up and by the time they are processed, they no longer match the event
|
||||
handler ID in the render tree
|
||||
</li>
|
||||
<li>
|
||||
By the time the render output is processed, the textbox contents have already been edited further.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
The point of this test is to show that even in this hostile environment, we don't break the ability
|
||||
to type normally, nor do we lose any of your typed characters.
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@code {
|
||||
string _inputText;
|
||||
|
||||
string InputText
|
||||
{
|
||||
get => _inputText;
|
||||
set
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
_inputText = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<p>
|
||||
This component represents a case that's difficult for the diff algorithm if it doesn't
|
||||
understand how the underlying DOM gets mutated when you check a box.
|
||||
</p>
|
||||
<p>
|
||||
If we didn't have the RenderTreeUpdater, then if you checked the first incomplete item,
|
||||
the diff algoritm would see the subsequent render has only one "todo" item left, and would
|
||||
match it with the existing 'li' element. Since that's still not done, the algorithm would
|
||||
think no change was needed to the checkbox. But since you just clicked that checkbox, the
|
||||
UI would show it as checked. It would look as if you have completed all four items instead
|
||||
of just three.
|
||||
</p>
|
||||
<p>
|
||||
RenderTreeUpdater fixes this by patching the old render tree to match the latest state of
|
||||
the DOM, so the diff algoritm sees it must explicitly uncheck the remaining 'todo' box.
|
||||
</p>
|
||||
|
||||
<h2>To do</h2>
|
||||
|
||||
<ul class="incomplete-items">
|
||||
@foreach (var item in items.Where(x => !x.IsDone))
|
||||
{
|
||||
<li>
|
||||
<input class="item-isdone" type="checkbox" @bind="@item.IsDone" />
|
||||
<span class="item-text">@item.Text</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<h2>Done</h2>
|
||||
|
||||
<ul class="complete-items">
|
||||
@foreach (var item in items.Where(x => x.IsDone))
|
||||
{
|
||||
<li>
|
||||
<input class="item-isdone" type="checkbox" @bind="@item.IsDone" />
|
||||
<span class="item-text">@item.Text</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
List<TodoItem> items = new List<TodoItem>
|
||||
{
|
||||
new TodoItem { Text = "Alpha" },
|
||||
new TodoItem { Text = "Beta" },
|
||||
new TodoItem { Text = "Gamma", IsDone = true },
|
||||
new TodoItem { Text = "Delta", IsDone = true },
|
||||
};
|
||||
|
||||
class TodoItem
|
||||
{
|
||||
public bool IsDone { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue