diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs index 9555b94191..b4f29e8e5c 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs @@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Blazor.Components /// Optional base class for Blazor components. Alternatively, Blazor components may /// implement directly. /// - public abstract class BlazorComponent : IComponent, IHandleEvent + public abstract class BlazorComponent : IComponent, IHandleEvent, IHandleAfterRender { /// /// Specifies the name of the -building method. @@ -87,6 +87,7 @@ namespace Microsoft.AspNetCore.Blazor.Components /// Method invoked when the component has received parameters from its parent in /// the render tree, and the incoming values have been assigned to properties. /// + /// A representing any asynchronous operation, or . protected virtual Task OnParametersSetAsync() => null; @@ -115,6 +116,22 @@ namespace Microsoft.AspNetCore.Blazor.Components protected virtual bool ShouldRender() => true; + /// + /// Method invoked after each time the component has been rendered. + /// + protected virtual void OnAfterRender() + { + } + + /// + /// Method invoked after each time the component has been rendered. Note that the component does + /// not automatically re-render after the completion of any returned , because + /// that would cause an infinite render loop. + /// + /// A representing any asynchronous operation, or . + protected virtual Task OnAfterRenderAsync() + => null; + void IComponent.Init(RenderHandle renderHandle) { // This implicitly means a BlazorComponent can only be associated with a single @@ -191,5 +208,24 @@ namespace Microsoft.AspNetCore.Blazor.Components task.ContinueWith(ContinueAfterLifecycleTask); } + + void IHandleAfterRender.OnAfterRender() + { + OnAfterRender(); + + OnAfterRenderAsync()?.ContinueWith(task => + { + // Note that we don't call StateHasChanged to trigger a render after + // handling this, because that would be an infinite loop. The only + // reason we have OnAfterRenderAsync is so that the developer doesn't + // have to use "async void" and do their own exception handling in + // the case where they want to start an async task. + + if (task.Exception != null) + { + HandleException(task.Exception); + } + }); + } } } diff --git a/src/Microsoft.AspNetCore.Blazor/Components/IHandleAfterRender.cs b/src/Microsoft.AspNetCore.Blazor/Components/IHandleAfterRender.cs new file mode 100644 index 0000000000..4ed3eaa505 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/IHandleAfterRender.cs @@ -0,0 +1,16 @@ +// 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.Blazor.Components +{ + /// + /// Interface implemented by components that receive notification that they have been rendered. + /// + public interface IHandleAfterRender + { + /// + /// Notifies the component that it has been rendered. + /// + void OnAfterRender(); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs b/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs index ddd30f0370..e981c9eba0 100644 --- a/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs +++ b/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs @@ -86,5 +86,8 @@ namespace Microsoft.AspNetCore.Blazor.Rendering $"events because it does not implement {typeof(IHandleEvent).FullName}."); } } + + public void NotifyRenderCompleted() + => (_component as IHandleAfterRender)?.OnAfterRender(); } } diff --git a/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs b/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs index 51ed35de4b..a6c5431547 100644 --- a/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs +++ b/src/Microsoft.AspNetCore.Blazor/Rendering/Renderer.cs @@ -164,7 +164,9 @@ namespace Microsoft.AspNetCore.Blazor.Rendering RenderInExistingBatch(nextToRender); } - UpdateDisplay(_batchBuilder.ToBatch()); + var batch = _batchBuilder.ToBatch(); + UpdateDisplay(batch); + InvokeRenderCompletedCalls(batch.UpdatedComponents); } finally { @@ -174,6 +176,17 @@ namespace Microsoft.AspNetCore.Blazor.Rendering } } + private void InvokeRenderCompletedCalls(ArrayRange updatedComponents) + { + var array = updatedComponents.Array; + for (var i = 0; i < updatedComponents.Count; i++) + { + // The component might be rendered and disposed in the same batch (if its parent + // was rendered later in the batch, and removed the child from the tree). + GetOptionalComponentState(array[i].ComponentId)?.NotifyRenderCompleted(); + } + } + private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { renderQueueEntry.ComponentState diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs index 33cbcc6a73..b19358a2cf 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs @@ -370,5 +370,13 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests resetButton.Click(); Assert.Equal("Current count: 0", appElement.FindElement(currentCountTextSelector).Text); } + + [Fact] + public void CanUseJsInteropForRefElementsDuringOnAfterRender() + { + var appElement = MountTestComponent(); + var inputElement = appElement.FindElement(By.TagName("input")); + Assert.Equal("Value set after render", inputElement.GetAttribute("value")); + } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs index 8d9a0f0557..87ba995fe7 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RendererTest.cs @@ -991,6 +991,86 @@ namespace Microsoft.AspNetCore.Blazor.Test }); } + [Fact] + public void CallsAfterRenderOnEachRender() + { + // Arrange + var onAfterRenderCallCountLog = new List(); + var component = new AfterRenderCaptureComponent(); + var renderer = new TestRenderer + { + OnUpdateDisplay = _ => onAfterRenderCallCountLog.Add(component.OnAfterRenderCallCount) + }; + renderer.AssignComponentId(component); + + // Act + component.TriggerRender(); + + // Assert + // When the display was first updated, OnAfterRender had not yet been called + Assert.Equal(new[] { 0 }, onAfterRenderCallCountLog); + // But OnAfterRender was called since then + Assert.Equal(1, component.OnAfterRenderCallCount); + + // Act/Assert 2: On a subsequent render, the same happens again + component.TriggerRender(); + Assert.Equal(new[] { 0, 1 }, onAfterRenderCallCountLog); + Assert.Equal(2, component.OnAfterRenderCallCount); + } + + [Fact] + public void DoesNotCallOnAfterRenderForComponentsNotRendered() + { + // Arrange + var showComponent3 = true; + var parentComponent = new TestComponent(builder => + { + // First child will be re-rendered because we'll change its param + builder.OpenComponent(0); + builder.AddAttribute(1, "some param", showComponent3); + builder.CloseComponent(); + + // Second child will not be re-rendered because nothing changes + builder.OpenComponent(2); + builder.CloseComponent(); + + // Third component will be disposed + if (showComponent3) + { + builder.OpenComponent(3); + builder.CloseComponent(); + } + }); + var renderer = new TestRenderer(); + var parentComponentId = renderer.AssignComponentId(parentComponent); + + // Act: First render + parentComponent.TriggerRender(); + + // Assert: All child components were notified of "after render" + var batch1 = renderer.Batches.Single(); + var parentComponentEdits1 = batch1.DiffsByComponentId[parentComponentId].Single().Edits; + var childComponents = parentComponentEdits1 + .Select( + edit => (AfterRenderCaptureComponent)batch1.ReferenceFrames[edit.ReferenceFrameIndex].Component) + .ToArray(); + Assert.Equal(1, childComponents[0].OnAfterRenderCallCount); + Assert.Equal(1, childComponents[1].OnAfterRenderCallCount); + Assert.Equal(1, childComponents[2].OnAfterRenderCallCount); + + // Act: Second render + showComponent3 = false; + parentComponent.TriggerRender(); + + // Assert: Only the re-rendered component was notified of "after render" + var batch2 = renderer.Batches.Skip(1).Single(); + Assert.Equal(2, batch2.DiffsInOrder.Count); // Parent and first child + Assert.Equal(1, batch2.DisposedComponentIDs.Count); // Third child + Assert.Equal(2, childComponents[0].OnAfterRenderCallCount); // Retained and re-rendered + Assert.Equal(1, childComponents[1].OnAfterRenderCallCount); // Retained and not re-rendered + Assert.Equal(1, childComponents[2].OnAfterRenderCallCount); // Disposed + } + private class NoOpRenderer : Renderer { public NoOpRenderer() : base(new TestServiceProvider()) @@ -1213,5 +1293,24 @@ namespace Microsoft.AspNetCore.Blazor.Test builder.CloseElement(); } } + + private class AfterRenderCaptureComponent : AutoRenderComponent, IComponent, IHandleAfterRender + { + public int OnAfterRenderCallCount { get; private set; } + + public void OnAfterRender() + { + OnAfterRenderCallCount++; + } + + void IComponent.SetParameters(ParameterCollection parameters) + { + TriggerRender(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } + } } } diff --git a/test/shared/TestRenderer.cs b/test/shared/TestRenderer.cs index 6d1bdc9ff9..2817338a8c 100644 --- a/test/shared/TestRenderer.cs +++ b/test/shared/TestRenderer.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Rendering; -using Microsoft.AspNetCore.Blazor.RenderTree; namespace Microsoft.AspNetCore.Blazor.Test.Helpers { @@ -20,6 +19,8 @@ namespace Microsoft.AspNetCore.Blazor.Test.Helpers { } + public Action OnUpdateDisplay { get; set; } + public List Batches { get; } = new List(); @@ -34,6 +35,8 @@ namespace Microsoft.AspNetCore.Blazor.Test.Helpers protected override void UpdateDisplay(RenderBatch renderBatch) { + OnUpdateDisplay?.Invoke(renderBatch); + var capturedBatch = new CapturedBatch(); Batches.Add(capturedBatch); diff --git a/test/testapps/BasicTestApp/AfterRenderInteropComponent.cshtml b/test/testapps/BasicTestApp/AfterRenderInteropComponent.cshtml new file mode 100644 index 0000000000..48740eef18 --- /dev/null +++ b/test/testapps/BasicTestApp/AfterRenderInteropComponent.cshtml @@ -0,0 +1,13 @@ +@using Microsoft.AspNetCore.Blazor +@using Microsoft.AspNetCore.Blazor.Browser.Interop + + + +@functions { + ElementRef myInput; + + protected override void OnAfterRender() + { + RegisteredFunction.Invoke("setElementValue", myInput, "Value set after render"); + } +} diff --git a/test/testapps/BasicTestApp/wwwroot/index.html b/test/testapps/BasicTestApp/wwwroot/index.html index fdc73eafe1..92376840ca 100644 --- a/test/testapps/BasicTestApp/wwwroot/index.html +++ b/test/testapps/BasicTestApp/wwwroot/index.html @@ -31,6 +31,7 @@ +