From 334b8308c9a93a7cfc0e797b5b4444e76530fe1c Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Wed, 22 Jul 2026 16:31:10 +0200 Subject: [PATCH] Coalesce component renders during Blazor WebAssembly startup #12753 --- .../Components/AppComponentBase.Rendering.cs | 87 +++++++++++++++++++ .../Components/AppComponentBase.cs | 40 +++++---- .../Components/Layout/AppAiChatPanel.razor.cs | 1 + .../Components/Layout/AppShell.razor.cs | 1 + .../Components/Layout/Header/Header.razor.cs | 1 + .../Components/Layout/NavBar.razor.cs | 1 + .../Components/Pages/Home/HomePage.razor.cs | 2 + 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.Rendering.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.Rendering.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.Rendering.cs new file mode 100644 index 0000000000..f1f2dc421f --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.Rendering.cs @@ -0,0 +1,87 @@ +namespace Boilerplate.Client.Core.Components; + +public partial class AppComponentBase +{ + /// + /// Opt-in flag (disabled by default). When enabled, the renders triggered during the app's + /// startup window are coalesced: a burst of calls + /// collapses into a single render once the burst settles, instead of re-rendering this component + /// (and its whole subtree) once per call. + /// + /// This targets Blazor WebAssembly startup cost only, so it is active exclusively in the browser. + /// The window is measured from app start (), components created after has elapsed + /// never coalesce and render normally, keeping steady-state interactivity immediately responsive. + /// + /// + protected virtual bool CoalesceRenders => false; + + /// How long after app start the coalescing stays active. + protected virtual TimeSpan CoalesceRendersDuration => TimeSpan.FromSeconds(3); + + /// The quiet period a render burst must settle for before it is flushed (extended on each new change). + protected virtual TimeSpan CoalesceRendersWindow => TimeSpan.FromMilliseconds(300); + + + private static readonly long startupTimestamp = Stopwatch.GetTimestamp(); + private bool passCoalescedRender; + private bool coalesceDisposed; + private ITimer? coalesceTimer; + + + protected override bool ShouldRender() + { + // Only Blazor WebAssembly (where startup rendering is the bottleneck); everywhere else render normally. + if (CoalesceRenders is false || AppPlatform.IsBrowser is false) + return base.ShouldRender(); + + // Past the startup window (measured from app start, not this component) → behave normally from here on. + if (Stopwatch.GetElapsedTime(startupTimestamp) >= CoalesceRendersDuration) + { + DisposeCoalesceTimer(); + return base.ShouldRender(); + } + + // A render we scheduled ourselves (the trailing edge), let it through. + if (passCoalescedRender) + { + passCoalescedRender = false; + return true; + } + + // Inside a burst: suppress this render, but (re)arm a single trailing render so the final + // startup state is guaranteed to paint. Created once (stopped), then each new change just + // resets the due time via Change, no re-allocation per suppressed render (debounce). + coalesceTimer ??= TimeProvider.CreateTimer(static state => + { + var self = (AppComponentBase)state!; + // The timer fires on a threadpool thread, hop back onto the renderer's sync context. + _ = self.InvokeAsync(() => + { + if (self.coalesceDisposed) return; // do not render after disposal + self.passCoalescedRender = true; + self.StateHasChanged(); + }); + }, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + coalesceTimer.Change(CoalesceRendersWindow, Timeout.InfiniteTimeSpan); + + return false; + } + + private void DisposeCoalesceTimer() + { + coalesceTimer?.Dispose(); + coalesceTimer = null; + } + + /// + /// Implementation of the partial declared in AppComponentBase.cs, invoked from + /// so the coalescing timer is always released while its whole + /// logic stays isolated in this file. Compiles away entirely when this partial is absent. + /// + partial void DisposeRenderCoalescing() + { + coalesceDisposed = true; + DisposeCoalesceTimer(); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs index 60cf56047f..5bed808594 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs @@ -10,44 +10,46 @@ public partial class AppComponentBase : OwningComponentBase, IAsyncDisposable [CascadingParameter(Name = Parameters.IsOnline)] protected bool? IsOnline { get; set; } [CascadingParameter] protected Task AuthenticationStateTask { get; set; } = default!; - [AutoInject] protected IJSRuntime JSRuntime = default!; + [AutoInject] private IServiceProvider serviceProvider = default!; - [AutoInject] protected IStorageService StorageService = default!; + protected IJSRuntime JSRuntime => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected JsonSerializerOptions JsonSerializerOptions = default!; + protected IStorageService StorageService => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected TimeProvider TimeProvider = default!; + protected JsonSerializerOptions JsonSerializerOptions => field ??= serviceProvider.GetRequiredService(); + + protected TimeProvider TimeProvider => field ??= serviceProvider.GetRequiredService(); /// /// /// - [AutoInject] protected PubSubService PubSubService = default!; + protected PubSubService PubSubService => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected IConfiguration Configuration = default!; + protected IConfiguration Configuration => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected NavigationManager NavigationManager = default!; + protected NavigationManager NavigationManager => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected IAuthTokenProvider AuthTokenProvider = default!; + protected IAuthTokenProvider AuthTokenProvider => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected IStringLocalizer Localizer = default!; + protected IStringLocalizer Localizer => field ??= serviceProvider.GetRequiredService>(); - [AutoInject] protected ClientExceptionHandlerBase ExceptionHandler = default!; + protected ClientExceptionHandlerBase ExceptionHandler => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected AuthManager AuthManager = default!; + protected AuthManager AuthManager => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected SnackBarService SnackBarService = default!; + protected SnackBarService SnackBarService => field ??= serviceProvider.GetRequiredService(); - [AutoInject] protected ITelemetryContext TelemetryContext = default!; + protected ITelemetryContext TelemetryContext => field ??= serviceProvider.GetRequiredService(); /// /// /// - [AutoInject] protected IAuthorizationService AuthorizationService = default!; + protected IAuthorizationService AuthorizationService => field ??= serviceProvider.GetRequiredService(); /// /// /// - [AutoInject] protected AbsoluteServerAddressProvider AbsoluteServerAddress { get; set; } = default!; + protected AbsoluteServerAddressProvider AbsoluteServerAddress => field ??= serviceProvider.GetRequiredService(); private CancellationTokenSource? cts = new(); @@ -265,8 +267,16 @@ protected async Task Abort() await currentCts.TryCancel(); } + /// + /// Implemented in the rendering partial (AppComponentBase.Rendering.cs) to release the + /// render-coalescing timer on disposal. Keeps that feature's logic fully isolated in its own file. + /// + partial void DisposeRenderCoalescing(); + public async ValueTask DisposeAsync() { + DisposeRenderCoalescing(); + try { if (cts != null) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.cs index 6c335a2062..5d4205b980 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.cs @@ -224,6 +224,7 @@ private async Task RestartChannel() await StartChannel(); } + protected override bool CoalesceRenders => true; protected override async ValueTask DisposeAsync(bool disposing) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppShell.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppShell.razor.cs index 205086186d..670b42f042 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppShell.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppShell.razor.cs @@ -75,6 +75,7 @@ private void NavigationManager_LocationChanged(object? sender, LocationChangedEv StateHasChanged(); } + protected override bool CoalesceRenders => true; protected override async ValueTask DisposeAsync(bool disposing) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/Header.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/Header.razor.cs index 68e162b996..64ff6418a7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/Header.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/Header.razor.cs @@ -48,6 +48,7 @@ private void NavigationManager_LocationChanged(object? sender, LocationChangedEv StateHasChanged(); } + protected override bool CoalesceRenders => true; protected override async ValueTask DisposeAsync(bool disposing) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/NavBar.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/NavBar.razor.cs index bbd566b26e..c99132fddd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/NavBar.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/NavBar.razor.cs @@ -2,4 +2,5 @@ namespace Boilerplate.Client.Core.Components.Layout; public partial class NavBar { + protected override bool CoalesceRenders => true; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Home/HomePage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Home/HomePage.razor.cs index 639c5f7204..aa4232c9bd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Home/HomePage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Home/HomePage.razor.cs @@ -77,4 +77,6 @@ private async Task HandleOnSearchBoxClick() PubSubService.Publish(ClientAppMessages.SEARCH_PRODUCTS); } //#endif + + protected override bool CoalesceRenders => true; }