diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts index 4b118fb490..871c60a0a5 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts @@ -90,20 +90,22 @@ export class BrowserRenderer { } } - insertFrame(componentId: number, parent: Element, childIndex: number, frames: System_Array, frame: RenderTreeFramePointer, frameIndex: number) { + insertFrame(componentId: number, parent: Element, childIndex: number, frames: System_Array, frame: RenderTreeFramePointer, frameIndex: number): number { const frameType = renderTreeFrame.frameType(frame); switch (frameType) { case FrameType.element: this.insertElement(componentId, parent, childIndex, frames, frame, frameIndex); - break; + return 1; case FrameType.text: this.insertText(parent, childIndex, frame); - break; + return 1; case FrameType.attribute: throw new Error('Attribute frames should only be present as leading children of element frames.'); case FrameType.component: this.insertComponent(parent, childIndex, frame); - break; + return 1; + case FrameType.region: + return this.insertFrameRange(componentId, parent, childIndex, frames, frameIndex + 1, frameIndex + renderTreeFrame.subtreeLength(frame)); default: const unknownType: never = frameType; // Compile-time verification that the switch was exhaustive throw new Error(`Unknown frame type: ${unknownType}`); @@ -199,11 +201,12 @@ export class BrowserRenderer { } } - insertFrameRange(componentId: number, parent: Element, childIndex: number, frames: System_Array, startIndex: number, endIndexExcl: number) { + insertFrameRange(componentId: number, parent: Element, childIndex: number, frames: System_Array, startIndex: number, endIndexExcl: number): number { + const origChildIndex = childIndex; for (let index = startIndex; index < endIndexExcl; index++) { const frame = getTreeFramePtr(frames, index); - this.insertFrame(componentId, parent, childIndex, frames, frame, index); - childIndex++; + const numChildrenInserted = this.insertFrame(componentId, parent, childIndex, frames, frame, index); + childIndex += numChildrenInserted; // Skip over any descendants, since they are already dealt with recursively const subtreeLength = renderTreeFrame.subtreeLength(frame); @@ -211,6 +214,8 @@ export class BrowserRenderer { index += subtreeLength - 1; } } + + return (childIndex - origChildIndex); // Total number of children inserted } } diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/RenderTreeFrame.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/RenderTreeFrame.ts index 9e735a3146..4d57ae8182 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/RenderTreeFrame.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/RenderTreeFrame.ts @@ -28,6 +28,7 @@ export enum FrameType { text = 2, attribute = 3, component = 4, + region = 5, } // Nominal type to ensure only valid pointers are passed to the renderTreeFrame functions. diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs index d21a2c915f..548e760cff 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs @@ -186,7 +186,7 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree /// Marks a previously appended region frame as closed. Calls to this method /// must be balanced with calls to . /// - internal void CloseRegion() + public void CloseRegion() { var indexOfEntryBeingClosed = _openElementIndices.Pop(); ref var entry = ref _entries.Buffer[indexOfEntryBeingClosed]; diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs index 7dff6aea27..5e2a55e568 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs @@ -165,6 +165,30 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests Assert.Equal("I computed: 202", computedValueElement.Text); } + [Fact] + public void CanRenderRegionsWhilePreservingSurroundingElements() + { + // Initially, the region isn't shown + var appElement = MountTestComponent(); + var originalButton = appElement.FindElement(By.TagName("button")); + var regionElements = appElement.FindElements(By.CssSelector("p[name=region-element]")); + Assert.Empty(regionElements); + + // The JS-side DOM builder handles regions correctly, placing elements + // after the region after the corresponding elements + Assert.Equal("The end", appElement.FindElements(By.CssSelector("div > *:last-child")).Single().Text); + + // When we click the button, the region is shown + originalButton.Click(); + regionElements = appElement.FindElements(By.CssSelector("p[name=region-element]")); + Assert.Single(regionElements); + + // The button itself was preserved, so we can click it again and see the effect + originalButton.Click(); + regionElements = appElement.FindElements(By.CssSelector("p[name=region-element]")); + Assert.Empty(regionElements); + } + private IWebElement MountTestComponent() where TComponent: IComponent { var componentTypeName = typeof(TComponent).FullName; diff --git a/test/testapps/BasicTestApp/RenderBlockComponent.cs b/test/testapps/BasicTestApp/RenderBlockComponent.cs new file mode 100644 index 0000000000..89f8adc772 --- /dev/null +++ b/test/testapps/BasicTestApp/RenderBlockComponent.cs @@ -0,0 +1,74 @@ +// 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.Blazor.Components; +using Microsoft.AspNetCore.Blazor.RenderTree; + +namespace BasicTestApp +{ + public class RenderBlockComponent : IComponent, IHandleEvent + { + private RenderHandle _renderHandle; + private bool _showRegion; + + // Important: Notice that the sequence numbers inside the region are higher + // that the sequence numbers outside it. Without the region delimiter, the + // differencer would think the following nodes had been removed, then the + // region was inserted, followed by a new copy of the following nodes. That's + // not as efficient and wouldn't preserve focus etc. + private Action _exampleRegion = builder => + { + // TODO: Support some kind of RenderBlock primitive + // which is an Action. Can use the + // same type name for RenderHandle.Render's arg. + builder.OpenElement(100, "p"); + builder.AddAttribute(101, "name", "region-element"); + builder.AddAttribute(102, "style", "color: red"); + builder.AddText(103, "This is from the region"); + builder.CloseElement(); + }; + + public void Init(RenderHandle renderHandle) + => _renderHandle = renderHandle; + + public void SetParameters(ParameterCollection parameters) + => Render(); + + public void HandleEvent(UIEventHandler handler, UIEventArgs args) + { + // TODO: Remove the necessity to implement IHandleEvent if you just want + // the event handler to be called. Then call Render from inside the handler. + handler(args); + Render(); + } + + private void Render() => _renderHandle.Render(builder => + { + builder.OpenElement(0, "div"); // Container so we can see that passing through regions is OK + builder.OpenRegion(1); + builder.AddText(2, "Region will be toggled below "); + + if (_showRegion) + { + builder.OpenRegion(3); + _exampleRegion(builder); + builder.CloseRegion(); + } + + builder.OpenElement(4, "button"); + builder.AddAttribute(5, "onclick", ToggleRegion); + builder.AddText(6, "Toggle"); + builder.CloseElement(); + + builder.CloseRegion(); + builder.OpenElement(7, "p"); + builder.AddText(8, "The end"); + builder.CloseElement(); + builder.CloseElement(); + }); + + private void ToggleRegion(UIEventArgs eventArgs) + => _showRegion = !_showRegion; + } +}