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;
}