Skip to content

Commit f780f84

Browse files
committed
Coalesce component renders during Blazor WebAssembly startup #1
1 parent 4bc0aea commit f780f84

7 files changed

Lines changed: 101 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
namespace Boilerplate.Client.Core.Components;
2+
3+
public partial class AppComponentBase
4+
{
5+
/// <summary>
6+
/// Opt-in flag (disabled by default). When enabled, the renders triggered during the app's
7+
/// startup window are coalesced: a burst of <see cref="ComponentBase.StateHasChanged"/> calls
8+
/// collapses into a single render once the burst settles, instead of re-rendering this component
9+
/// (and its whole subtree) once per call.
10+
/// <para>
11+
/// This targets Blazor WebAssembly startup cost only, so it is active exclusively in the browser.
12+
/// The window is measured from app start (<see cref="startupTimestamp"/>), components created after <see cref="CoalesceRendersDuration"/> has elapsed
13+
/// never coalesce and render normally, keeping steady-state interactivity immediately responsive.
14+
/// </para>
15+
/// </summary>
16+
protected virtual bool CoalesceRenders => false;
17+
18+
/// <summary>How long after app start the coalescing stays active.</summary>
19+
protected virtual TimeSpan CoalesceRendersDuration => TimeSpan.FromSeconds(3);
20+
21+
/// <summary>The quiet period a render burst must settle for before it is flushed (extended on each new change).</summary>
22+
protected virtual TimeSpan CoalesceRendersWindow => TimeSpan.FromMilliseconds(300);
23+
24+
25+
private static readonly long startupTimestamp = Stopwatch.GetTimestamp();
26+
private bool passCoalescedRender = true;
27+
private bool coalesceDisposed;
28+
private ITimer? coalesceTimer;
29+
30+
31+
protected override bool ShouldRender()
32+
{
33+
// Only Blazor WebAssembly (where startup rendering is the bottleneck); everywhere else render normally.
34+
if (CoalesceRenders is false || AppPlatform.IsBrowser is false)
35+
return base.ShouldRender();
36+
37+
// Past the startup window (measured from app start, not this component) → behave normally from here on.
38+
if (Stopwatch.GetElapsedTime(startupTimestamp) >= CoalesceRendersDuration)
39+
{
40+
DisposeCoalesceTimer();
41+
return base.ShouldRender();
42+
}
43+
44+
// A render we scheduled ourselves (the trailing edge), let it through.
45+
if (passCoalescedRender)
46+
{
47+
passCoalescedRender = false;
48+
return true;
49+
}
50+
51+
// Inside a burst: suppress this render, but (re)arm a single trailing render so the final
52+
// startup state is guaranteed to paint. Created once (stopped), then each new change just
53+
// resets the due time via Change, no re-allocation per suppressed render (debounce).
54+
coalesceTimer ??= TimeProvider.CreateTimer(static state =>
55+
{
56+
var self = (AppComponentBase)state!;
57+
// The timer fires on a threadpool thread, hop back onto the renderer's sync context.
58+
_ = self.InvokeAsync(() =>
59+
{
60+
if (self.coalesceDisposed) return; // do not render after disposal
61+
self.passCoalescedRender = true;
62+
self.StateHasChanged();
63+
});
64+
}, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
65+
66+
coalesceTimer.Change(CoalesceRendersWindow, Timeout.InfiniteTimeSpan);
67+
68+
return false;
69+
}
70+
71+
private void DisposeCoalesceTimer()
72+
{
73+
coalesceTimer?.Dispose();
74+
coalesceTimer = null;
75+
}
76+
77+
/// <summary>
78+
/// Implementation of the partial declared in <c>AppComponentBase.cs</c>, invoked from
79+
/// <see cref="DisposeAsync()"/> so the coalescing timer is always released while its whole
80+
/// logic stays isolated in this file. Compiles away entirely when this partial is absent.
81+
/// </summary>
82+
partial void DisposeRenderCoalescing()
83+
{
84+
coalesceDisposed = true;
85+
DisposeCoalesceTimer();
86+
}
87+
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,16 @@ protected async Task Abort()
265265
await currentCts.TryCancel();
266266
}
267267

268+
/// <summary>
269+
/// Implemented in the rendering partial (<c>AppComponentBase.Rendering.cs</c>) to release the
270+
/// render-coalescing timer on disposal. Keeps that feature's logic fully isolated in its own file.
271+
/// </summary>
272+
partial void DisposeRenderCoalescing();
273+
268274
public async ValueTask DisposeAsync()
269275
{
276+
DisposeRenderCoalescing();
277+
270278
try
271279
{
272280
if (cts != null)

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ private async Task RestartChannel()
224224
await StartChannel();
225225
}
226226

227+
protected override bool CoalesceRenders => true;
227228

228229
protected override async ValueTask DisposeAsync(bool disposing)
229230
{

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppShell.razor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ private void NavigationManager_LocationChanged(object? sender, LocationChangedEv
7575
StateHasChanged();
7676
}
7777

78+
protected override bool CoalesceRenders => true;
7879

7980
protected override async ValueTask DisposeAsync(bool disposing)
8081
{

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/Header.razor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ private void NavigationManager_LocationChanged(object? sender, LocationChangedEv
4848
StateHasChanged();
4949
}
5050

51+
protected override bool CoalesceRenders => true;
5152

5253
protected override async ValueTask DisposeAsync(bool disposing)
5354
{

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/NavBar.razor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ namespace Boilerplate.Client.Core.Components.Layout;
22

33
public partial class NavBar
44
{
5+
protected override bool CoalesceRenders => true;
56
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Home/HomePage.razor.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,6 @@ private async Task HandleOnSearchBoxClick()
7777
PubSubService.Publish(ClientAppMessages.SEARCH_PRODUCTS);
7878
}
7979
//#endif
80+
81+
protected override bool CoalesceRenders => true;
8082
}

0 commit comments

Comments
 (0)