|
| 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 | +} |
0 commit comments