diff --git a/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs b/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs index 7b434ba13fc7..fb49307851f7 100644 --- a/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs +++ b/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs @@ -79,7 +79,7 @@ public RenderTreeDiffBuilderBenchmark() [Benchmark(Description = "RenderTreeDiffBuilder: Input and validation on a single form field.", Baseline = true)] public void ComputeDiff_SingleFormField() { - builder.Clear(); + builder.ClearStateForCurrentBatch(); var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, original.GetFrames(), modified.GetFrames()); GC.KeepAlive(diff); } diff --git a/src/Components/Components/src/Rendering/RenderBatchBuilder.cs b/src/Components/Components/src/Rendering/RenderBatchBuilder.cs index bde7ea58f991..4bcdd82d0aaf 100644 --- a/src/Components/Components/src/Rendering/RenderBatchBuilder.cs +++ b/src/Components/Components/src/Rendering/RenderBatchBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Collections.Generic; @@ -30,11 +30,17 @@ internal class RenderBatchBuilder // Scratch data structure for understanding attribute diffs. public Dictionary AttributeDiffSet { get; } = new Dictionary(); - public void Clear() + public void ClearStateForCurrentBatch() { + // This method is used to reset the builder back to a default state so it can + // begin building the next batch. That means clearing all the tracked state, but + // *not* clearing ComponentRenderQueue because that may hold information about + // the next batch we want to build. We shouldn't ever need to clear + // ComponentRenderQueue explicitly, because it gets cleared as an aspect of + // processing the render queue. + EditsBuffer.Clear(); ReferenceFramesBuffer.Clear(); - ComponentRenderQueue.Clear(); UpdatedComponentDiffs.Clear(); DisposedComponentIds.Clear(); DisposedEventHandlerIds.Clear(); diff --git a/src/Components/Components/src/Rendering/Renderer.cs b/src/Components/Components/src/Rendering/Renderer.cs index a8354a2c1a7d..22c75e64f7f1 100644 --- a/src/Components/Components/src/Rendering/Renderer.cs +++ b/src/Components/Components/src/Rendering/Renderer.cs @@ -447,9 +447,18 @@ private void ProcessRenderQueue() finally { RemoveEventHandlerIds(_batchBuilder.DisposedEventHandlerIds.ToRange(), updateDisplayTask); - _batchBuilder.Clear(); + _batchBuilder.ClearStateForCurrentBatch(); _isBatchInProgress = false; } + + // An OnAfterRenderAsync callback might have queued more work synchronously. + // Note: we do *not* re-render implicitly after the OnAfterRenderAsync-returned + // task (that would be an infinite loop). We only render after an explicit render + // request (e.g., StateHasChanged()). + if (_batchBuilder.ComponentRenderQueue.Count > 0) + { + ProcessRenderQueue(); + } } private Task InvokeRenderCompletedCalls(ArrayRange updatedComponents) diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 03d871c8ae30..b70d6b09f492 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -2507,6 +2507,31 @@ public void DoesNotCallOnAfterRenderForComponentsNotRendered() Assert.Equal(1, childComponents[2].OnAfterRenderCallCount); // Disposed } + [Fact] + public void CanTriggerRenderingSynchronouslyFromInsideAfterRenderCallback() + { + // Arrange + AfterRenderCaptureComponent component = null; + component = new AfterRenderCaptureComponent + { + OnAfterRenderLogic = () => + { + if (component.OnAfterRenderCallCount < 10) + { + component.TriggerRender(); + } + } + }; + var renderer = new TestRenderer(); + renderer.AssignRootComponentId(component); + + // Act + component.TriggerRender(); + + // Assert + Assert.Equal(10, component.OnAfterRenderCallCount); + } + [ConditionalFact] [SkipOnHelix] // https://github.com/aspnet/AspNetCore/issues/7487 public async Task CanTriggerEventHandlerDisposedInEarlierPendingBatchAsync() @@ -3414,11 +3439,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) private class AfterRenderCaptureComponent : AutoRenderComponent, IComponent, IHandleAfterRender { + public Action OnAfterRenderLogic { get; set; } + public int OnAfterRenderCallCount { get; private set; } public Task OnAfterRenderAsync() { OnAfterRenderCallCount++; + OnAfterRenderLogic?.Invoke(); return Task.CompletedTask; } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs index 38673a9639cc..33c11a1771c7 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/PrerenderingTest.cs @@ -39,6 +39,21 @@ public void CanTransitionFromPrerenderedToInteractiveMode() Browser.Equal("1", () => Browser.FindElement(By.Id("count")).Text); } + [Fact] + public void CanUseJSInteropFromOnAfterRenderAsync() + { + Navigate("/prerendered/prerendered-interop"); + + // Prerendered output can't use JSInterop + Browser.Equal("No value yet", () => Browser.FindElement(By.Id("val-get-by-interop")).Text); + Browser.Equal(string.Empty, () => Browser.FindElement(By.Id("val-set-by-interop")).GetAttribute("value")); + + // Once connected, we can + BeginInteractivity(); + Browser.Equal("Hello from interop call", () => Browser.FindElement(By.Id("val-get-by-interop")).Text); + Browser.Equal("Hello from interop call", () => Browser.FindElement(By.Id("val-set-by-interop")).GetAttribute("value")); + } + private void BeginInteractivity() { Browser.FindElement(By.Id("load-boot-script")).Click(); diff --git a/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs b/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs index cfea7f70ac37..4e7461c7fa7c 100644 --- a/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs +++ b/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs @@ -590,6 +590,14 @@ public void CanDispatchAsyncWorkToSyncContext() Browser.Equal("First Second Third Fourth Fifth", () => result.Text); } + [Fact] + public void CanPerformInteropImmediatelyOnComponentInsertion() + { + var appElement = MountTestComponent(); + Browser.Equal("Hello from interop call", () => appElement.FindElement(By.Id("val-get-by-interop")).Text); + Browser.Equal("Hello from interop call", () => appElement.FindElement(By.Id("val-set-by-interop")).GetAttribute("value")); + } + static IAlert SwitchToAlert(IWebDriver driver) { try diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 4b5a04c54a29..76c5881080a4 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -49,6 +49,7 @@ + @if (SelectedComponentType != null) diff --git a/src/Components/test/testassets/BasicTestApp/InteropOnInitializationComponent.razor b/src/Components/test/testassets/BasicTestApp/InteropOnInitializationComponent.razor new file mode 100644 index 000000000000..084061e4017d --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/InteropOnInitializationComponent.razor @@ -0,0 +1,52 @@ +@page "/prerendered-interop" +@using Microsoft.AspNetCore.Components.Services +@using Microsoft.JSInterop +@inject IComponentContext ComponentContext +@inject IJSRuntime JSRuntime + +

+ This component shows it's possible to use JSInterop as part of the initialization + logic of a component, and have that be compatible with prerendering. It also shows + that it's possible to trigger a rendering update from inside OnAfterRenderAsync, + though it's the developer's own responsibility to avoid an infinite loop when + doing that. +

+ +

+ Value get via JS interop call: + @(infoFromJs ?? "No value yet") +

+ +

+ Value set via JS interop call: + +

+ +@functions { + string infoFromJs; + ElementRef myElem; + + protected override async Task OnAfterRenderAsync() + { + // TEMPORARY: Currently we need this guard to avoid making the interop + // call during prerendering. Soon this will be unnecessary because we + // will change OnAfterRenderAsync not to run during the prerendering phase. + if (!ComponentContext.IsConnected) + { + return; + } + + if (infoFromJs == null) + { + // We can only use the ElementRef in OnAfterRenderAsync (and not any + // earlier lifecycle method), because there is no JS element until + // the component has been rendered. + infoFromJs = await JSRuntime.InvokeAsync( + "setElementValue", myElem, "Hello from interop call"); + + // Now we can re-render with the new state obtained from the interop call. + // Not an infinite loop, because we only call this when "infoFromJs == null" + StateHasChanged(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html index edd3f4121447..57b0015a9f07 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html @@ -16,6 +16,7 @@ // Used by ElementRefComponent function setElementValue(element, newValue) { element.value = newValue; + return element.value; } (function () { diff --git a/src/Components/test/testassets/TestServer/Pages/PrerenderedHost.cshtml b/src/Components/test/testassets/TestServer/Pages/PrerenderedHost.cshtml index 02c033a91771..628265b26bca 100644 --- a/src/Components/test/testassets/TestServer/Pages/PrerenderedHost.cshtml +++ b/src/Components/test/testassets/TestServer/Pages/PrerenderedHost.cshtml @@ -22,6 +22,12 @@ scriptElem.src = '_framework/components.server.js'; document.body.appendChild(scriptElem); } + + // Used by InteropOnInitializationComponent + function setElementValue(element, newValue) { + element.value = newValue; + return element.value; + }