diff --git a/samples/HostedInAspNet.Client/Program.cs b/samples/HostedInAspNet.Client/Program.cs index bcd36e2473..c16d768296 100644 --- a/samples/HostedInAspNet.Client/Program.cs +++ b/samples/HostedInAspNet.Client/Program.cs @@ -20,6 +20,10 @@ namespace HostedInAspNet.Client internal class MyComponent : IComponent { + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "h1"); diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs index 90f7c30186..44a9c200b4 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs @@ -13,6 +13,21 @@ namespace Microsoft.AspNetCore.Blazor.Components /// public abstract class BlazorComponent : IComponent, IHandlePropertiesChanged { + private RenderHandle _renderHandle; + + void IComponent.Init(RenderHandle renderHandle) + { + // This implicitly means a BlazorComponent can only be associated with a single + // renderer. That's the only use case we have right now. If there was ever a need, + // a component could hold a collection of render handles. + if (_renderHandle.IsInitalised) + { + throw new InvalidOperationException($"The render handle is already set. Cannot initialize a {nameof(BlazorComponent)} more than once."); + } + + _renderHandle = renderHandle; + } + /// public virtual void BuildRenderTree(RenderTreeBuilder builder) { diff --git a/src/Microsoft.AspNetCore.Blazor/Components/IComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/IComponent.cs index 446c620b74..494d2e1ac8 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/IComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/IComponent.cs @@ -10,6 +10,12 @@ namespace Microsoft.AspNetCore.Blazor.Components /// public interface IComponent { + /// + /// Initializes the component. + /// + /// A that allows the component to be rendered. + void Init(RenderHandle renderHandle); + /// /// Builds a representing the current state of the component. /// diff --git a/src/Microsoft.AspNetCore.Blazor/Components/RenderHandle.cs b/src/Microsoft.AspNetCore.Blazor/Components/RenderHandle.cs new file mode 100644 index 0000000000..df8031045a --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/RenderHandle.cs @@ -0,0 +1,43 @@ +// 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.Blazor.Rendering; +using System; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + /// + /// Allows a component to notify the renderer that it should be rendered. + /// + public readonly struct RenderHandle + { + private readonly Renderer _renderer; + private readonly int _componentId; + + internal RenderHandle(Renderer renderer, int componentId) + { + _renderer = renderer ?? throw new System.ArgumentNullException(nameof(renderer)); + _componentId = componentId; + } + + /// + /// Gets a value that indicates whether the has been + /// initialised and is ready to use. + /// + public bool IsInitalised + => _renderer != null; + + /// + /// Notifies the renderer that the component should be rendered. + /// + public void Render() + { + if (_renderer == null) + { + throw new InvalidOperationException("The render handle is not yet assigned."); + } + + _renderer.ComponentRequestedRender(_componentId); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs b/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs index b47d135a7d..e512ecff1b 100644 --- a/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs +++ b/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs @@ -54,6 +54,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering var componentId = _nextComponentId++; var componentState = new ComponentState(this, componentId, component); _componentStateById.Add(componentId, componentState); + component.Init(new RenderHandle(this, componentId)); return componentId; } @@ -155,6 +156,20 @@ namespace Microsoft.AspNetCore.Blazor.Rendering frame = frame.WithAttributeEventHandlerId(id); } + internal void ComponentRequestedRender(int componentId) + { + // TODO: Clean up the locking around rendering. The Renderer doesn't really need + // to be thread-safe, and the following code isn't actually thread-safe anyway. + if (_renderBatchLock == 0) + { + RenderNewBatch(componentId); + } + else + { + _sharedRenderBatchBuilder.ComponentRenderQueue.Enqueue(componentId); + } + } + private ComponentState GetRequiredComponentState(int componentId) => _componentStateById.TryGetValue(componentId, out var componentState) ? componentState diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs index 9c140e7368..b0f8022e3e 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -478,6 +478,10 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public class TestComponent : IComponent { + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(0, $"Hello from {nameof(TestComponent)}"); diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs index 6d00b77159..a24d6e90e4 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs @@ -291,6 +291,8 @@ namespace Microsoft.AspNetCore.Blazor.Test private class TestComponent : IComponent { + public void Init(RenderHandle renderHandle) { } + public void BuildRenderTree(RenderTreeBuilder builder) => throw new NotImplementedException(); } diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs index 7b6a59c059..5faf3c4ae0 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs @@ -798,13 +798,14 @@ namespace Microsoft.AspNetCore.Blazor.Test public string ReadonlyProperty { get; private set; } private string PrivateProperty { get; set; } - public void BuildRenderTree(RenderTreeBuilder builder) - { - } + public void Init(RenderHandle renderHandle) { } + public void BuildRenderTree(RenderTreeBuilder builder) { } } private class FakeComponent2 : IComponent { + public void Init(RenderHandle renderHandle) { } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(100, $"Hello from {nameof(FakeComponent2)}"); @@ -815,11 +816,16 @@ namespace Microsoft.AspNetCore.Blazor.Test { public int DisposalCount { get; private set; } public void Dispose() => DisposalCount++; + + public void Init(RenderHandle renderHandle) { } + public void BuildRenderTree(RenderTreeBuilder builder) { } } private class NonDisposableComponent : IComponent { + public void Init(RenderHandle renderHandle) { } + public void BuildRenderTree(RenderTreeBuilder builder) { } } diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs index 6b16735276..7f8300bd49 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs @@ -641,6 +641,110 @@ namespace Microsoft.AspNetCore.Blazor.Test Assert.Equal(1, eventCount); } + [Fact] + public void ComponentCannotTriggerRenderBeforeRenderHandleAssigned() + { + // Arrange + var component = new TestComponent(builder => { }); + + // Act/Assert + var ex = Assert.Throws(() => + { + component.TriggerRender(); + }); + Assert.Equal("The render handle is not yet assigned.", ex.Message); + } + + [Fact] + public void ComponentCanTriggerRenderWhenNoBatchIsInProgress() + { + // Arrange + var renderer = new TestRenderer(); + var renderCount = 0; + var component = new TestComponent(builder => + { + builder.AddText(0, $"Render count: {++renderCount}"); + }); + var componentId = renderer.AssignComponentId(component); + + // Act/Assert: Can trigger initial render + Assert.Equal(0, renderCount); + component.TriggerRender(); + Assert.Equal(1, renderCount); + var batch1 = renderer.Batches.Single(); + var edit1 = batch1.DiffsByComponentId[componentId].Single().Edits.Single(); + Assert.Equal(RenderTreeEditType.PrependFrame, edit1.Type); + AssertFrame.Text(batch1.ReferenceFrames[edit1.ReferenceFrameIndex], + "Render count: 1", 0); + + // Act/Assert: Can trigger subsequent render + component.TriggerRender(); + Assert.Equal(2, renderCount); + var batch2 = renderer.Batches.Skip(1).Single(); + var edit2 = batch2.DiffsByComponentId[componentId].Single().Edits.Single(); + Assert.Equal(RenderTreeEditType.UpdateText, edit2.Type); + AssertFrame.Text(batch2.ReferenceFrames[edit2.ReferenceFrameIndex], + "Render count: 2", 0); + } + + [Fact] + public void ComponentCanTriggerRenderWhenExistingBatchIsInProgress() + { + // Arrange + var renderer = new TestRenderer(); + TestComponent parent = null; + var parentRenderCount = 0; + parent = new TestComponent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ReRendersParentComponent.Parent), parent); + builder.CloseComponent(); + builder.AddText(2, $"Parent render count: {++parentRenderCount}"); + }); + var parentComponentId = renderer.AssignComponentId(parent); + + // Act + renderer.RenderNewBatch(parentComponentId); + + // Assert + var batch = renderer.Batches.Single(); + Assert.Equal(3, batch.DiffsInOrder.Count); + + // First is the parent component's initial render + var diff1 = batch.DiffsInOrder[0]; + Assert.Equal(parentComponentId, diff1.ComponentId); + Assert.Collection(diff1.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Component( + batch.ReferenceFrames[edit.ReferenceFrameIndex]); + }, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Parent render count: 1"); + }); + + // Second is the child component's single render + var diff2 = batch.DiffsInOrder[1]; + Assert.NotEqual(parentComponentId, diff2.ComponentId); + var diff2edit = diff2.Edits.Single(); + Assert.Equal(RenderTreeEditType.PrependFrame, diff2edit.Type); + AssertFrame.Text(batch.ReferenceFrames[diff2edit.ReferenceFrameIndex], + "Child is here"); + + // Third is the parent's triggered render + var diff3 = batch.DiffsInOrder[2]; + Assert.Equal(parentComponentId, diff3.ComponentId); + var diff3edit = diff3.Edits.Single(); + Assert.Equal(RenderTreeEditType.UpdateText, diff3edit.Type); + AssertFrame.Text(batch.ReferenceFrames[diff3edit.ReferenceFrameIndex], + "Parent render count: 2"); + } + private class NoOpRenderer : Renderer { public new int AssignComponentId(IComponent component) @@ -690,6 +794,9 @@ namespace Microsoft.AspNetCore.Blazor.Test public IDictionary> DiffsByComponentId { get; } = new Dictionary>(); + public IList DiffsInOrder { get; } + = new List(); + public IList DisposedComponentIDs { get; set; } public RenderTreeFrame[] ReferenceFrames { get; set; } @@ -702,14 +809,17 @@ namespace Microsoft.AspNetCore.Blazor.Test } // Clone the diff, because its underlying storage will get reused in subsequent batches - DiffsByComponentId[componentId].Add(new RenderTreeDiff( + var diffClone = new RenderTreeDiff( diff.ComponentId, - new ArraySegment(diff.Edits.ToArray()))); + new ArraySegment(diff.Edits.ToArray())); + DiffsByComponentId[componentId].Add(diffClone); + DiffsInOrder.Add(diffClone); } } private class TestComponent : IComponent { + private RenderHandle _renderHandle; private Action _renderAction; public TestComponent(Action renderAction) @@ -717,14 +827,26 @@ namespace Microsoft.AspNetCore.Blazor.Test _renderAction = renderAction; } + public void Init(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + public void BuildRenderTree(RenderTreeBuilder builder) => _renderAction(builder); + + public void TriggerRender() + => _renderHandle.Render(); } private class MessageComponent : IComponent { public string Message { get; set; } + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(0, Message); @@ -737,6 +859,10 @@ namespace Microsoft.AspNetCore.Blazor.Test public string StringProperty { get; set; } public object ObjectProperty { get; set; } + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { } @@ -747,6 +873,10 @@ namespace Microsoft.AspNetCore.Blazor.Test public UIEventHandler Handler { get; set; } public bool SkipElement { get; set; } + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "grandparent"); @@ -770,6 +900,10 @@ namespace Microsoft.AspNetCore.Blazor.Test public bool IncludeChild { get; set; } public IDictionary ChildParameters { get; set; } + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(0, "Parent here"); @@ -796,6 +930,10 @@ namespace Microsoft.AspNetCore.Blazor.Test public int IntProperty { get; set; } + public void Init(RenderHandle renderHandle) + { + } + public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(0, $"Notifications: {NotificationsCount}"); @@ -806,21 +944,24 @@ namespace Microsoft.AspNetCore.Blazor.Test NotificationsCount++; } } - - (int, T) FirstWithIndex(IEnumerable items, Predicate predicate) + + private class ReRendersParentComponent : IComponent { - var index = 0; - foreach (var item in items) + public TestComponent Parent { get; set; } + private bool _isFirstTime = true; + + public void Init(RenderHandle renderHandle) { } + + public void BuildRenderTree(RenderTreeBuilder builder) { - if (predicate(item)) + if (_isFirstTime) // Don't want an infinite loop { - return (index, item); + _isFirstTime = false; + Parent.TriggerRender(); } - index++; + builder.AddText(0, "Child is here"); } - - throw new InvalidOperationException("No matching element was found."); } } }