From 974ccb9988ce134ca607ac384b210d96f1a6f1bb Mon Sep 17 00:00:00 2001 From: Oleksandr Liakhevych Date: Tue, 5 Sep 2023 02:17:49 +0300 Subject: [PATCH] Support ReplaceChildElement in ElementManager --- src/BlazorBindings.Core/ElementManager.cs | 11 + .../IContainerElementHandler.cs | 6 + .../NativeComponentAdapter.cs | 220 ++++++++++++------ .../NativeComponentRenderer.cs | 10 +- .../Elements/FlyoutPage.cs | 9 +- .../Internal/ContentPropertyComponent.cs | 5 + .../Internal/ListContentPropertyComponent.cs | 8 + .../Elements/ShellContent.cs | 13 +- .../Components/TestContainerComponent.cs | 64 +++++ .../Elements/ElementTestBase.razor | 82 +++---- .../NativeComponentRendererTests.razor | 38 +++ 11 files changed, 337 insertions(+), 129 deletions(-) create mode 100644 src/BlazorBindings.UnitTests/Components/TestContainerComponent.cs create mode 100644 src/BlazorBindings.UnitTests/NativeComponentRendererTests.razor diff --git a/src/BlazorBindings.Core/ElementManager.cs b/src/BlazorBindings.Core/ElementManager.cs index ae79e639..4cc379c0 100644 --- a/src/BlazorBindings.Core/ElementManager.cs +++ b/src/BlazorBindings.Core/ElementManager.cs @@ -53,4 +53,15 @@ public virtual void RemoveChildElement(IElementHandler parentHandler, IElementHa $"(child type is '{childHandler.TargetElement?.GetType().FullName}')."); } } + + public virtual void ReplaceChildElement(IElementHandler parentHandler, IElementHandler oldChild, IElementHandler newChild, int physicalSiblingIndex) + { + if (oldChild is INonPhysicalChild || newChild is INonPhysicalChild) + throw new NotSupportedException("NonPhysicalChild elements cannot be replaced."); + + if (parentHandler is not IContainerElementHandler container) + throw new InvalidOperationException($"Handler of type '{parentHandler.GetType().FullName}' does not support replacing child elements."); + + container.ReplaceChild(physicalSiblingIndex, oldChild.TargetElement, newChild.TargetElement); + } } diff --git a/src/BlazorBindings.Core/IContainerElementHandler.cs b/src/BlazorBindings.Core/IContainerElementHandler.cs index 6fae58f1..8e366129 100644 --- a/src/BlazorBindings.Core/IContainerElementHandler.cs +++ b/src/BlazorBindings.Core/IContainerElementHandler.cs @@ -7,4 +7,10 @@ public interface IContainerElementHandler : IElementHandler { void AddChild(object child, int physicalSiblingIndex); void RemoveChild(object child, int physicalSiblingIndex); + + void ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild) + { + RemoveChild(oldChild, physicalSiblingIndex); + AddChild(newChild, physicalSiblingIndex); + } } diff --git a/src/BlazorBindings.Core/NativeComponentAdapter.cs b/src/BlazorBindings.Core/NativeComponentAdapter.cs index 74131754..b467fa10 100644 --- a/src/BlazorBindings.Core/NativeComponentAdapter.cs +++ b/src/BlazorBindings.Core/NativeComponentAdapter.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; using System.Diagnostics; @@ -13,60 +12,71 @@ namespace BlazorBindings.Core; [DebuggerDisplay("{DebugName}")] internal sealed class NativeComponentAdapter : IDisposable { - private static volatile int DebugInstanceCounter; - public NativeComponentAdapter(NativeComponentRenderer renderer, NativeComponentAdapter closestPhysicalParent, IElementHandler knownTargetElement = null) { Renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _closestPhysicalParent = closestPhysicalParent; _targetElement = knownTargetElement; + } + - // Assign unique counter value. This *should* all be done on one thread, but just in case, make it thread-safe. - _debugInstanceCounterValue = Interlocked.Increment(ref DebugInstanceCounter); + /// + /// Used for debugging purposes. + /// + public string Name { get; internal set; } + + private string Text + { + get + { + try + { + return (_targetElement?.TargetElement as dynamic)?.Text; + } + catch + { + return null; + } + } } - private readonly int _debugInstanceCounterValue; + private string DebugName => $"[\"{Text}\" {Name}"; - private string DebugName => $"[#{_debugInstanceCounterValue}] {Name}"; + public int DeepLevel { get; init; } public NativeComponentAdapter Parent { get; private set; } public List Children { get; } = new(); private readonly NativeComponentAdapter _closestPhysicalParent; private IElementHandler _targetElement; - private IComponent _targetComponent; private NativeComponentAdapter PhysicalTarget => _targetElement != null ? this : _closestPhysicalParent; public NativeComponentRenderer Renderer { get; } - /// - /// Used for debugging purposes. - /// - public string Name { get; internal set; } + private List<(int Index, NativeComponentAdapter ElementToRemove, NativeComponentAdapter ElementToAdd)> _pendingEdits; - public override string ToString() + internal void ApplyEdits( + int componentId, + ArrayBuilderSegment edits, + RenderBatch batch, + HashSet adaptersWithPendingEdits) { - return $"{nameof(NativeComponentAdapter)}: Name={Name ?? ""}, Target={_targetElement?.GetType().Name ?? ""}, #Children={Children.Count}"; - } - - internal void ApplyEdits(int componentId, ArrayBuilderSegment edits, ArrayRange referenceFrames, RenderBatch batch, HashSet processedComponentIds) - { - Renderer.Dispatcher.AssertAccess(); + var referenceFrames = batch.ReferenceFrames.Array; foreach (var edit in edits) { switch (edit.Type) { case RenderTreeEditType.PrependFrame: - ApplyPrependFrame(batch, componentId, edit.SiblingIndex, referenceFrames.Array, edit.ReferenceFrameIndex, processedComponentIds); + ApplyPrependFrame(batch, componentId, edit.SiblingIndex, referenceFrames, edit.ReferenceFrameIndex, adaptersWithPendingEdits); break; case RenderTreeEditType.RemoveFrame: - ApplyRemoveFrame(edit.SiblingIndex); + ApplyRemoveFrame(edit.SiblingIndex, adaptersWithPendingEdits); break; case RenderTreeEditType.UpdateText: { - var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var frame = referenceFrames[edit.ReferenceFrameIndex]; if (_targetElement is IHandleChildContentText handleChildContentText) { handleChildContentText.HandleText(edit.SiblingIndex, frame.TextContent); @@ -78,10 +88,6 @@ internal void ApplyEdits(int componentId, ArrayBuilderSegment ed break; } case RenderTreeEditType.StepIn: - { - // TODO: Need to implement this. For now it seems safe to ignore. - break; - } case RenderTreeEditType.StepOut: { // TODO: Need to implement this. For now it seems safe to ignore. @@ -89,7 +95,7 @@ internal void ApplyEdits(int componentId, ArrayBuilderSegment ed } case RenderTreeEditType.UpdateMarkup: { - var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex]; + var frame = referenceFrames[edit.ReferenceFrameIndex]; if (!string.IsNullOrWhiteSpace(frame.MarkupContent)) throw new NotImplementedException($"Not supported edit type: {edit.Type}"); @@ -101,39 +107,102 @@ internal void ApplyEdits(int componentId, ArrayBuilderSegment ed } } - private void ApplyRemoveFrame(int siblingIndex) + // a) We want to add child element from the deepest element to the top one, so that elements are added to parents with all the required changes. + // b) If elements are replaced, we want to have a single edit instead of two separate ones (remove+add) - it's more efficient, and + // the only way in some cases (when elements don't support empty content). + // Therefore we store all add/remove actions, and apply them (rearranged) after other edits. + public void ApplyPendingEdits() + { + if (_pendingEdits == null) + return; + + for (var i = 0; i < _pendingEdits.Count; i++) + { + var (index, elementToRemove, elementToAdd) = _pendingEdits[i]; + var nextEdit = _pendingEdits.ElementAtOrDefault(i + 1); + + // If we have two consequent edits (Add -> Remove or Remove -> Add) for the same index, + // and non of them are INonPhysicalChild elements, + // we try to replace them instead of adding and removing separately. + if (nextEdit.Index == index) + { + if (elementToRemove is not null and not { _targetElement: INonPhysicalChild } + && nextEdit.ElementToAdd is not null and not { _targetElement: INonPhysicalChild }) + { + Renderer.ElementManager.ReplaceChildElement(_targetElement, elementToRemove._targetElement, nextEdit.ElementToAdd._targetElement, index); + i++; + continue; + } + else if (elementToAdd is not null and not { _targetElement: INonPhysicalChild } + && nextEdit.ElementToRemove is not null and not { _targetElement: INonPhysicalChild }) + { + Renderer.ElementManager.ReplaceChildElement(_targetElement, nextEdit.ElementToRemove._targetElement, elementToAdd._targetElement, index); + i++; + continue; + } + } + + if (elementToRemove != null) + Renderer.ElementManager.RemoveChildElement(_targetElement, elementToRemove._targetElement, index); + + if (elementToAdd != null) + Renderer.ElementManager.AddChildElement(_targetElement, elementToAdd._targetElement, index); + } + + _pendingEdits.Clear(); + } + + private void AddPendingRemoval(NativeComponentAdapter childToRemove, int index, HashSet adaptersWithPendingEdits) + { + var targetEdits = PhysicalTarget._pendingEdits ??= new(); + targetEdits.Add((index, childToRemove, null)); + adaptersWithPendingEdits.Add(PhysicalTarget); + } + + private void AddPendingAddition(NativeComponentAdapter childToAdd, int index, HashSet adaptersWithPendingEdits) + { + var targetEdits = PhysicalTarget._pendingEdits ??= new(); + targetEdits.Add((index, null, childToAdd)); + adaptersWithPendingEdits.Add(PhysicalTarget); + } + + private void ApplyRemoveFrame(int siblingIndex, HashSet adaptersWithPendingEdits) { var childToRemove = Children[siblingIndex]; - RemoveChildElementAndDescendants(childToRemove); + RemoveChildElementAndDescendants(childToRemove, adaptersWithPendingEdits); Children.RemoveAt(siblingIndex); } - private void RemoveChildElementAndDescendants(NativeComponentAdapter childToRemove) + private void RemoveChildElementAndDescendants(NativeComponentAdapter childToRemove, HashSet adaptersWithPendingEdits) { - if (childToRemove._targetElement != null) + if (childToRemove?._targetElement != null) { // This adapter represents a physical element, so by removing it, we implicitly // remove all descendants. var index = PhysicalTarget.GetChildPhysicalIndex(childToRemove); - Renderer.ElementManager.RemoveChildElement(PhysicalTarget._targetElement, childToRemove._targetElement, index); + PhysicalTarget.AddPendingRemoval(childToRemove, index, adaptersWithPendingEdits); if (PhysicalTarget._targetElement is INonPhysicalChild { ShouldAddChildrenToParent: true }) { - // Since element was added to parent previosly, we have to remove it from there. - PhysicalTarget.Parent.PhysicalTarget.RemoveChildElementAndDescendants(childToRemove); + // Since element was added to parent previously, we have to remove it from there. + PhysicalTarget.Parent.RemoveChildElementAndDescendants(childToRemove, adaptersWithPendingEdits); } } - else + else if (childToRemove != null) { // This adapter is just a container for other adapters - foreach (var child in childToRemove.Children) - { - childToRemove.RemoveChildElementAndDescendants(child); - } + for (int i = 0; i < childToRemove.Children.Count; i++) + childToRemove.ApplyRemoveFrame(i, adaptersWithPendingEdits); } } - private int ApplyPrependFrame(RenderBatch batch, int componentId, int siblingIndex, RenderTreeFrame[] frames, int frameIndex, HashSet processedComponentIds) + private int ApplyPrependFrame( + RenderBatch batch, + int componentId, + int siblingIndex, + RenderTreeFrame[] frames, + int frameIndex, + HashSet adaptersWithPendingEdits) { ref var frame = ref frames[frameIndex]; switch (frame.FrameType) @@ -142,36 +211,14 @@ private int ApplyPrependFrame(RenderBatch batch, int componentId, int siblingInd { var childAdapter = AddChildAdapter(siblingIndex, frame); - // For most elements we should add element as child after all properties to have them fully initialized before rendering. - // However, INonPhysicalChild elements are not real elements, but apply to parent instead, therefore should be added as child before any properties are set. - if (childAdapter._targetElement is INonPhysicalChild) - { - AddElementAsChildElement(childAdapter); - } - - // Apply edits for child component recursively. - // That is done to fully initialize elements before adding to the UI tree. - processedComponentIds.Add(frame.ComponentId); - - for (var i = 0; i < batch.UpdatedComponents.Count; i++) - { - var componentEdits = batch.UpdatedComponents.Array[i]; - if (componentEdits.ComponentId == frame.ComponentId && componentEdits.Edits.Count > 0) - { - childAdapter.ApplyEdits(frame.ComponentId, componentEdits.Edits, batch.ReferenceFrames, batch, processedComponentIds); - } - } - - if (childAdapter._targetElement is not INonPhysicalChild and not null) - { - AddElementAsChildElement(childAdapter); - } + if (childAdapter._targetElement is not null) + AddElementAsChildElement(childAdapter, adaptersWithPendingEdits); return 1; } case RenderTreeFrameType.Region: { - return InsertFrameRange(batch, componentId, siblingIndex, frames, frameIndex + 1, frameIndex + frame.RegionSubtreeLength, processedComponentIds); + return InsertFrameRange(batch, componentId, siblingIndex, frames, frameIndex + 1, frameIndex + frame.RegionSubtreeLength, adaptersWithPendingEdits); } case RenderTreeFrameType.Markup: { @@ -179,7 +226,8 @@ private int ApplyPrependFrame(RenderBatch batch, int componentId, int siblingInd { throw new NotImplementedException($"Not supported frame type: {frame.FrameType}"); } - AddChildAdapter(siblingIndex, frame); + // We don't need any adapter for Markup frames, but we care about frame position, therefore we simply insert null here. + Children.Insert(siblingIndex, null); return 1; } case RenderTreeFrameType.Text: @@ -193,7 +241,8 @@ private int ApplyPrependFrame(RenderBatch batch, int componentId, int siblingInd var typeName = _targetElement?.TargetElement?.GetType()?.Name; throw new NotImplementedException($"Element {typeName} does not support text content: " + frame.MarkupContent); } - AddChildAdapter(siblingIndex, frame); + // We don't need any adapter for Text frames, but we care about frame position, therefore we simply insert null here. + Children.Insert(siblingIndex, null); return 1; } default: @@ -204,14 +253,27 @@ private int ApplyPrependFrame(RenderBatch batch, int componentId, int siblingInd /// /// Add element as a child element for closest physical parent. /// - private void AddElementAsChildElement(NativeComponentAdapter childAdapter) + private void AddElementAsChildElement(NativeComponentAdapter childAdapter, HashSet adaptersWithPendingEdits) { + if (childAdapter is null) + return; + var elementIndex = PhysicalTarget.GetChildPhysicalIndex(childAdapter); - Renderer.ElementManager.AddChildElement(PhysicalTarget._targetElement, childAdapter._targetElement, elementIndex); + + // For most elements we should add element as child after all properties to have them fully initialized before rendering. + // However, INonPhysicalChild elements are not real elements, but apply to parent instead, therefore should be added as child before any properties are set. + if (childAdapter._targetElement is INonPhysicalChild) + { + Renderer.ElementManager.AddChildElement(PhysicalTarget._targetElement, childAdapter._targetElement, elementIndex); + } + else + { + AddPendingAddition(childAdapter, elementIndex, adaptersWithPendingEdits); + } if (PhysicalTarget._targetElement is INonPhysicalChild { ShouldAddChildrenToParent: true }) { - PhysicalTarget.Parent.AddElementAsChildElement(childAdapter); + PhysicalTarget.Parent.AddElementAsChildElement(childAdapter, adaptersWithPendingEdits); } } @@ -243,6 +305,9 @@ static bool FindChildPhysicalIndexRecursive(NativeComponentAdapter parent, Nativ { foreach (var child in parent.Children) { + if (child is null) + continue; + if (child == targetChild) return true; @@ -262,13 +327,20 @@ static bool FindChildPhysicalIndexRecursive(NativeComponentAdapter parent, Nativ } } - private int InsertFrameRange(RenderBatch batch, int componentId, int childIndex, RenderTreeFrame[] frames, int startIndex, int endIndexExcl, HashSet processedComponentIds) + private int InsertFrameRange( + RenderBatch batch, + int componentId, + int childIndex, + RenderTreeFrame[] frames, + int startIndex, + int endIndexExcl, + HashSet adaptersWithPendingEdits) { var origChildIndex = childIndex; for (var frameIndex = startIndex; frameIndex < endIndexExcl; frameIndex++) { ref var frame = ref batch.ReferenceFrames.Array[frameIndex]; - var numChildrenInserted = ApplyPrependFrame(batch, componentId, childIndex, frames, frameIndex, processedComponentIds); + var numChildrenInserted = ApplyPrependFrame(batch, componentId, childIndex, frames, frameIndex, adaptersWithPendingEdits); childIndex += numChildrenInserted; // Skip over any descendants, since they are already dealt with recursively @@ -302,12 +374,12 @@ private NativeComponentAdapter AddChildAdapter(int siblingIndex, RenderTreeFrame var childAdapter = new NativeComponentAdapter(Renderer, PhysicalTarget) { Parent = this, - Name = name + Name = name, + DeepLevel = DeepLevel + 1 }; if (frame.FrameType is RenderTreeFrameType.Component) { - childAdapter._targetComponent = frame.Component; Renderer.RegisterComponentAdapter(childAdapter, frame.ComponentId); if (frame.Component is IElementHandler targetHandler) diff --git a/src/BlazorBindings.Core/NativeComponentRenderer.cs b/src/BlazorBindings.Core/NativeComponentRenderer.cs index 99f08fd5..5d6eae5e 100644 --- a/src/BlazorBindings.Core/NativeComponentRenderer.cs +++ b/src/BlazorBindings.Core/NativeComponentRenderer.cs @@ -85,21 +85,23 @@ public void RemoveRootComponent(IComponent component) protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { - HashSet processedComponentIds = new HashSet(); + HashSet adaptersWithPendingEdits = new(); var numUpdatedComponents = renderBatch.UpdatedComponents.Count; for (var componentIndex = 0; componentIndex < numUpdatedComponents; componentIndex++) { var updatedComponent = renderBatch.UpdatedComponents.Array[componentIndex]; - // If UpdatedComponent is already processed (due to recursive ApplyEdits) - skip it. - if (updatedComponent.Edits.Count > 0 && !processedComponentIds.Contains(updatedComponent.ComponentId)) + if (updatedComponent.Edits.Count > 0) { var adapter = _componentIdToAdapter[updatedComponent.ComponentId]; - adapter.ApplyEdits(updatedComponent.ComponentId, updatedComponent.Edits, renderBatch.ReferenceFrames, renderBatch, processedComponentIds); + adapter.ApplyEdits(updatedComponent.ComponentId, updatedComponent.Edits, renderBatch, adaptersWithPendingEdits); } } + foreach (var adapter in adaptersWithPendingEdits.OrderByDescending(a => a.DeepLevel)) + adapter.ApplyPendingEdits(); + var numDisposedComponents = renderBatch.DisposedComponentIDs.Count; for (var i = 0; i < numDisposedComponents; i++) { diff --git a/src/BlazorBindings.Maui/Elements/FlyoutPage.cs b/src/BlazorBindings.Maui/Elements/FlyoutPage.cs index e88c64fb..229a533d 100644 --- a/src/BlazorBindings.Maui/Elements/FlyoutPage.cs +++ b/src/BlazorBindings.Maui/Elements/FlyoutPage.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Components.Rendering; +using BlazorBindings.Maui.Extensions; +using Microsoft.AspNetCore.Components.Rendering; using MC = Microsoft.Maui.Controls; namespace BlazorBindings.Maui.Elements; @@ -24,12 +25,8 @@ protected override void RenderAdditionalPartialElementContent(RenderTreeBuilder RenderTreeBuilderHelper.AddContentProperty(builder, sequence++, Detail, (page, value) => { - // We cannot set Detail to null. An actual page will probably be set on next invocation anyway. - if (value == null) - return; - if (value is not MC.NavigationPage navigationPage) - navigationPage = new MC.NavigationPage((MC.Page)value); + navigationPage = new MC.NavigationPage(value.Cast()); page.Detail = navigationPage; }); diff --git a/src/BlazorBindings.Maui/Elements/Internal/ContentPropertyComponent.cs b/src/BlazorBindings.Maui/Elements/Internal/ContentPropertyComponent.cs index 5d2355ef..dc01d101 100644 --- a/src/BlazorBindings.Maui/Elements/Internal/ContentPropertyComponent.cs +++ b/src/BlazorBindings.Maui/Elements/Internal/ContentPropertyComponent.cs @@ -31,6 +31,11 @@ void IContainerElementHandler.RemoveChild(object child, int physicalSiblingIndex SetPropertyAction(_parent, null); } + void IContainerElementHandler.ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild) + { + SetPropertyAction(_parent, newChild); + } + // Because this is a 'fake' element, all matters related to physical trees // should be no-ops. object IElementHandler.TargetElement => null; diff --git a/src/BlazorBindings.Maui/Elements/Internal/ListContentPropertyComponent.cs b/src/BlazorBindings.Maui/Elements/Internal/ListContentPropertyComponent.cs index 00313228..63bfd790 100644 --- a/src/BlazorBindings.Maui/Elements/Internal/ListContentPropertyComponent.cs +++ b/src/BlazorBindings.Maui/Elements/Internal/ListContentPropertyComponent.cs @@ -1,4 +1,5 @@ using BlazorBindings.Maui.Extensions; +using System.Diagnostics; namespace BlazorBindings.Maui.Elements.Internal; @@ -33,6 +34,13 @@ void IContainerElementHandler.AddChild(object child, int physicalSiblingIndex) void IContainerElementHandler.RemoveChild(object child, int physicalSiblingIndex) { + Debug.Assert(_propertyItems[physicalSiblingIndex] == child); _propertyItems.Remove(child.Cast()); } + + void IContainerElementHandler.ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild) + { + Debug.Assert(_propertyItems[physicalSiblingIndex] == oldChild); + _propertyItems[physicalSiblingIndex] = newChild.Cast(); + } } diff --git a/src/BlazorBindings.Maui/Elements/ShellContent.cs b/src/BlazorBindings.Maui/Elements/ShellContent.cs index 9fcf3037..71acda6b 100644 --- a/src/BlazorBindings.Maui/Elements/ShellContent.cs +++ b/src/BlazorBindings.Maui/Elements/ShellContent.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using Microsoft.AspNetCore.Components.Rendering; +using System.Diagnostics; using MC = Microsoft.Maui.Controls; namespace BlazorBindings.Maui.Elements; @@ -42,10 +43,14 @@ void IContainerElementHandler.AddChild(object child, int physicalSiblingIndex) void IContainerElementHandler.RemoveChild(object child, int physicalSiblingIndex) { - if (NativeControl.Content == child) - { - NativeControl.Content = null; - } + Debug.Assert(NativeControl.Content == child); + NativeControl.Content = null; + } + + void IContainerElementHandler.ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild) + { + Debug.Assert(NativeControl.Content == oldChild); + NativeControl.Content = newChild; } protected override void RenderAdditionalPartialElementContent(RenderTreeBuilder builder, ref int sequence) diff --git a/src/BlazorBindings.UnitTests/Components/TestContainerComponent.cs b/src/BlazorBindings.UnitTests/Components/TestContainerComponent.cs new file mode 100644 index 00000000..bc2d0910 --- /dev/null +++ b/src/BlazorBindings.UnitTests/Components/TestContainerComponent.cs @@ -0,0 +1,64 @@ +using BlazorBindings.Core; +using BlazorBindings.Maui.Extensions; +using System.Collections.Immutable; + +namespace BlazorBindings.UnitTests.Components; + +public class TestContainerComponent : NativeControlComponentBase, IElementHandler, IContainerElementHandler +{ + private TestTargetElement _targetElement = new(); + + [Parameter] public int X { get; set; } + [Parameter] public int Y { get; set; } + [Parameter] public RenderFragment ChildContent { get; set; } + + protected override RenderFragment GetChildContent() => ChildContent; + + public override async Task SetParametersAsync(ParameterView parameters) + { + await base.SetParametersAsync(parameters); + _targetElement.X = X; + _targetElement.Y = Y; + } + + public object TargetElement => _targetElement; + + void IContainerElementHandler.AddChild(object child, int physicalSiblingIndex) + { + _targetElement.Children = _targetElement.Children.Insert(physicalSiblingIndex, child.Cast()); + } + + void IContainerElementHandler.RemoveChild(object child, int physicalSiblingIndex) + { + if (!Equals(_targetElement.Children[physicalSiblingIndex], child)) + throw new InvalidOperationException("Unexpected child to remove."); + + _targetElement.Children = _targetElement.Children.RemoveAt(physicalSiblingIndex); + } + + void IContainerElementHandler.ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild) + { + if (!Equals(_targetElement.Children[physicalSiblingIndex], oldChild)) + throw new InvalidOperationException("Unexpected child to remove."); + + _targetElement.Children = _targetElement.Children.SetItem(physicalSiblingIndex, newChild.Cast()); + } + + // We want to track element state when it is added to parent, therefore mutable struct is used. + public record struct TestTargetElement + { + public TestTargetElement() : this(0, 0) { } + + public TestTargetElement(int x, int y) + { + X = x; + Y = y; + Children = ImmutableList.Create(); + } + + public int X { get; set; } + public int Y { get; set; } + + public ImmutableList Children { get; set; } + } +} diff --git a/src/BlazorBindings.UnitTests/Elements/ElementTestBase.razor b/src/BlazorBindings.UnitTests/Elements/ElementTestBase.razor index a9d704cf..14dd57ae 100644 --- a/src/BlazorBindings.UnitTests/Elements/ElementTestBase.razor +++ b/src/BlazorBindings.UnitTests/Elements/ElementTestBase.razor @@ -6,58 +6,58 @@ @implements IHandleEvent @code { -public ElementTestBase() -{ - MC.Application.Current = new TestApplication(); -} - -private TestBlazorBindingsRenderer _renderer = (TestBlazorBindingsRenderer)TestBlazorBindingsRenderer.Create(); -private RenderFragmentComponent _renderedComponent; + public ElementTestBase() + { + MC.Application.Current = new TestApplication(); + } -protected async Task Render(RenderFragment renderFragment) where T : MC.BindableObject -{ - var container = new RootContainerHandler(); - _renderedComponent = await _renderer.AddComponent(container, new Dictionary - { - ["RenderFragment"] = renderFragment - }); + private TestBlazorBindingsRenderer _renderer = (TestBlazorBindingsRenderer)TestBlazorBindingsRenderer.Create(); + private RenderFragmentComponent _renderedComponent; - return (T)container.Elements[0]; -} + protected async Task Render(RenderFragment renderFragment) + { + var container = new RootContainerHandler(); + _renderedComponent = await _renderer.AddComponent(container, new Dictionary + { + ["RenderFragment"] = renderFragment + }); -protected new void StateHasChanged() -{ - _renderedComponent?.StateHasChanged(); - ThrowOnException(); -} + return (T)container.Elements[0]; + } -protected void ThrowOnException() -{ - if (_renderer.Exceptions.Count > 0) + protected new void StateHasChanged() { - ExceptionDispatchInfo.Throw(_renderer.Exceptions[0]); + _renderedComponent?.StateHasChanged(); + ThrowOnException(); } -} -Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg) -{ - var task = (_renderedComponent as IHandleEvent)?.HandleEventAsync(callback, arg); - ThrowOnException(); - return task ?? Task.CompletedTask; -} - -private class RenderFragmentComponent : ComponentBase -{ - [Parameter] public RenderFragment RenderFragment { get; set; } + protected void ThrowOnException() + { + if (_renderer.Exceptions.Count > 0) + { + ExceptionDispatchInfo.Throw(_renderer.Exceptions[0]); + } + } - protected override void BuildRenderTree(RenderTreeBuilder builder) + Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg) { - RenderFragment(builder); + var task = (_renderedComponent as IHandleEvent)?.HandleEventAsync(callback, arg); + ThrowOnException(); + return task ?? Task.CompletedTask; } - public new void StateHasChanged() + private class RenderFragmentComponent : ComponentBase { - base.StateHasChanged(); + [Parameter] public RenderFragment RenderFragment { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderFragment(builder); + } + + public new void StateHasChanged() + { + base.StateHasChanged(); + } } } -} diff --git a/src/BlazorBindings.UnitTests/NativeComponentRendererTests.razor b/src/BlazorBindings.UnitTests/NativeComponentRendererTests.razor new file mode 100644 index 00000000..8535003f --- /dev/null +++ b/src/BlazorBindings.UnitTests/NativeComponentRendererTests.razor @@ -0,0 +1,38 @@ +@using BlazorBindings.UnitTests.Components; +@using BlazorBindings.UnitTests.Elements; + +@inherits ElementTestBase + +@code +{ + [Test] + public async Task TargetElementsShouldHavePropertiesSetWhenAddedToParents() + { + var parent = await Render( + @ + + + + + + + + + + ); + + Assert.That((parent.X, parent.Y), Is.EqualTo((1, 2))); + + var child0 = parent.Children[0]; + var child1 = parent.Children[1]; + + Assert.That((child0.X, child0.Y), Is.EqualTo((3, 4))); + Assert.That((child1.X, child1.Y), Is.EqualTo((9, 10))); + + var child00 = child0.Children[0]; + var child01 = child0.Children[1]; + + Assert.That((child00.X, child00.Y), Is.EqualTo((5, 6))); + Assert.That((child01.X, child01.Y), Is.EqualTo((7, 8))); + } +} \ No newline at end of file