Background
Investigating #67012 (a Blazor app whose memory grows until the OS kills it after a hot reload), the captured dump shows a single RenderTreeDiff[] from the shared ArrayPool doubling up to 6 GB inside one render batch:
SharedArrayPool<T>.Rent(Int32)
ComponentState.RenderIntoBatch(RenderBatchBuilder, RenderFragment, Exception)
Renderer.ProcessRenderQueue()
ComponentBase.StateHasChanged()
ComponentBase.CallOnParametersSetAsync()
ComponentBase.SetParametersAsync(ParameterView)
ComponentState.SupplyCombinedParameters(ParameterView)
ComponentState.SetDirectParameters(ParameterView)
Renderer.<RenderRootComponentsOnHotReload>b__64_0()
...
HotReloadAgent.ApplyManagedCodeUpdates(IEnumerable<T>)
That array is RenderBatchBuilder.UpdatedComponentDiffs, an ArrayBuilder<RenderTreeDiff> that doubles on each grow and gets one entry appended per component render within a single batch. 6 GB of it means hundreds of millions of component renders happened in one batch before the process was killed: a runaway synchronous re-render loop, not ordinary growth.
This is not a regression. The relevant render/hot-reload code (Renderer, ComponentBase, RenderTreeDiffBuilder) is byte-for-byte identical across all 10.0 patches (v10.0.0..v10.0.9); the behavior is long-standing.
Root cause
On a metadata update we re-render the whole tree and bypass ShouldRender() for the duration of the pass so that even components that would normally skip rendering pick up edits. Two mechanisms drive this:
-
The diff walks the tree and force-calls SetDirectParameters on every component even when parameters are unchanged (RenderTreeDiffBuilder.cs, the isHotReload branch around the RenderTreeFrameType.Component case).
-
ComponentBase.StateHasChanged ignores ShouldRender() while the metadata-update flag is set:
// src/Components/Components/src/ComponentBase.cs
if (_hasNeverRendered || ShouldRender() || _renderHandle.IsRenderingOnMetadataUpdate)
The flag IsRenderingOnMetadataUpdate is latched for the entire synchronous pass (set/reset only in the InvokeAsync lambda in RenderRootComponentsOnHotReload, src/Components/Components/src/RenderTree/Renderer.cs). So while the pass runs, every StateHasChanged bypasses ShouldRender.
For a normal app this is harmless: each component renders once and the batch drains. But if an app relies on ShouldRender() == false to break a synchronous re-render cycle (e.g. rendering one component synchronously triggers StateHasChanged() on another, directly or via a shared state container / cascading value / synchronously-invoked EventCallback), then during hot reload that brake is removed for the whole pass and the cycle never terminates. ProcessRenderQueue's drain loop keeps re-queuing and appending diffs until OOM.
Proposal (Option A): scope the ShouldRender bypass to each component's first render in the pass
Force the ShouldRender bypass only until each component has rendered once during the metadata-update pass, then let ShouldRender resume for any further StateHasChanged within the same pass.
- Track, per
ComponentState (or via a HashSet<int> of component IDs on the batch/renderer), whether a component has already force-rendered during the current metadata-update pass. Reset at the start of each pass.
- In
ComponentBase.StateHasChanged, the IsRenderingOnMetadataUpdate clause applies only if this component has not yet rendered in the current pass.
Why this preserves "everything re-renders" (no stale data): the "render once" guarantee does not come from the StateHasChanged bypass. It comes from the diff force-calling SetDirectParameters top-down on every component (mechanism 1 above). That parameter flow already re-renders every component exactly once regardless of ShouldRender. The StateHasChanged bypass only affects additional renders, which is precisely the part that forms the loop. Restricting the bypass to the first render keeps full tree re-rendering while letting a component's own ShouldRender() == false terminate any synchronous cycle on the second pass.
Optional Option B (defense in depth)
Add a sanity cap on how many times a single component can render within one batch, failing fast with a diagnostic that names the component rather than OOMing. This would also catch the equivalent runtime infinite-StateHasChanged loop that exists today without hot reload (it OOMs the same way, just less commonly hit because ShouldRender normally breaks it). Blunt instrument with an arbitrary threshold, so a secondary guardrail rather than the primary fix.
Validation / regression test
There is existing infrastructure to drive this without the real hot-reload agent:
src/Components/Components/test/RendererTest.cs already simulates deltas via an injectable HotReloadManager and hotReloadManager.TriggerOnDeltaApplied() (see HotReload_ReRenderPreservesAsyncLocalValues, DisposingRenderer_UnsubsribesFromHotReloadManager). Set AppContext.SetSwitch("System.Reflection.Metadata.MetadataUpdater.IsSupported", true), assign renderer.HotReloadManager = new HotReloadManager().
TestRenderer (src/Components/Shared/test/TestRenderer.cs) exposes Batches (a List<CapturedBatch>) and renders synchronously, so per-component render counts are observable.
Suggested test shape:
- Build a small component graph that reproduces the cycle: a parent that renders two children where one child overrides
ShouldRender() => false and, during render, synchronously causes the other to re-render (e.g. via a shared state object mutated in BuildRenderTree, or a synchronous StateHasChanged on a sibling through a cascading value). Each test component should self-limit (e.g. throw after N renders) so that, before the fix, the test fails fast instead of hanging/OOMing CI.
- Trigger an initial render, then
hotReloadManager.TriggerOnDeltaApplied().
- Assert the batch completes and each component rendered a bounded number of times (e.g. exactly once for the forced pass, and the
ShouldRender() => false component is not re-rendered again by the cycle), i.e. Batches does not grow without bound and UpdatedComponentDiffs stays small.
Existing assets that help author the components: src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithShouldRender.razor (a ShouldRender() => false component) and RenderOnHotReload.razor, which already verify that ShouldRender() => false components are force-rendered on reload, the behavior we must keep while terminating the loop.
Acceptance criteria
- On a metadata update, every component still re-renders exactly once (no stale UI).
- A component that uses
ShouldRender() == false to break a synchronous re-render cycle no longer loops unboundedly during hot reload; the batch terminates.
- Regression test in
RendererTest.cs covering the cycle-under-hot-reload scenario.
Repro requirements (for #67012)
This is not reproducible from a blank Blazor project; it requires a component that (a) overrides ShouldRender() to return false and (b) synchronously triggers StateHasChanged() during rendering. A minimal repro of that shape would become the regression test.
Related: #67012, #59027
Background
Investigating #67012 (a Blazor app whose memory grows until the OS kills it after a hot reload), the captured dump shows a single
RenderTreeDiff[]from the sharedArrayPooldoubling up to 6 GB inside one render batch:That array is
RenderBatchBuilder.UpdatedComponentDiffs, anArrayBuilder<RenderTreeDiff>that doubles on each grow and gets one entry appended per component render within a single batch. 6 GB of it means hundreds of millions of component renders happened in one batch before the process was killed: a runaway synchronous re-render loop, not ordinary growth.This is not a regression. The relevant render/hot-reload code (
Renderer,ComponentBase,RenderTreeDiffBuilder) is byte-for-byte identical across all 10.0 patches (v10.0.0..v10.0.9); the behavior is long-standing.Root cause
On a metadata update we re-render the whole tree and bypass
ShouldRender()for the duration of the pass so that even components that would normally skip rendering pick up edits. Two mechanisms drive this:The diff walks the tree and force-calls
SetDirectParameterson every component even when parameters are unchanged (RenderTreeDiffBuilder.cs, theisHotReloadbranch around theRenderTreeFrameType.Componentcase).ComponentBase.StateHasChangedignoresShouldRender()while the metadata-update flag is set:The flag
IsRenderingOnMetadataUpdateis latched for the entire synchronous pass (set/reset only in theInvokeAsynclambda inRenderRootComponentsOnHotReload,src/Components/Components/src/RenderTree/Renderer.cs). So while the pass runs, everyStateHasChangedbypassesShouldRender.For a normal app this is harmless: each component renders once and the batch drains. But if an app relies on
ShouldRender() == falseto break a synchronous re-render cycle (e.g. rendering one component synchronously triggersStateHasChanged()on another, directly or via a shared state container / cascading value / synchronously-invokedEventCallback), then during hot reload that brake is removed for the whole pass and the cycle never terminates.ProcessRenderQueue's drain loop keeps re-queuing and appending diffs until OOM.Proposal (Option A): scope the
ShouldRenderbypass to each component's first render in the passForce the
ShouldRenderbypass only until each component has rendered once during the metadata-update pass, then letShouldRenderresume for any furtherStateHasChangedwithin the same pass.ComponentState(or via aHashSet<int>of component IDs on the batch/renderer), whether a component has already force-rendered during the current metadata-update pass. Reset at the start of each pass.ComponentBase.StateHasChanged, theIsRenderingOnMetadataUpdateclause applies only if this component has not yet rendered in the current pass.Why this preserves "everything re-renders" (no stale data): the "render once" guarantee does not come from the
StateHasChangedbypass. It comes from the diff force-callingSetDirectParameterstop-down on every component (mechanism 1 above). That parameter flow already re-renders every component exactly once regardless ofShouldRender. TheStateHasChangedbypass only affects additional renders, which is precisely the part that forms the loop. Restricting the bypass to the first render keeps full tree re-rendering while letting a component's ownShouldRender() == falseterminate any synchronous cycle on the second pass.Optional Option B (defense in depth)
Add a sanity cap on how many times a single component can render within one batch, failing fast with a diagnostic that names the component rather than OOMing. This would also catch the equivalent runtime infinite-
StateHasChangedloop that exists today without hot reload (it OOMs the same way, just less commonly hit becauseShouldRendernormally breaks it). Blunt instrument with an arbitrary threshold, so a secondary guardrail rather than the primary fix.Validation / regression test
There is existing infrastructure to drive this without the real hot-reload agent:
src/Components/Components/test/RendererTest.csalready simulates deltas via an injectableHotReloadManagerandhotReloadManager.TriggerOnDeltaApplied()(seeHotReload_ReRenderPreservesAsyncLocalValues,DisposingRenderer_UnsubsribesFromHotReloadManager). SetAppContext.SetSwitch("System.Reflection.Metadata.MetadataUpdater.IsSupported", true), assignrenderer.HotReloadManager = new HotReloadManager().TestRenderer(src/Components/Shared/test/TestRenderer.cs) exposesBatches(aList<CapturedBatch>) and renders synchronously, so per-component render counts are observable.Suggested test shape:
ShouldRender() => falseand, during render, synchronously causes the other to re-render (e.g. via a shared state object mutated inBuildRenderTree, or a synchronousStateHasChangedon a sibling through a cascading value). Each test component should self-limit (e.g. throw after N renders) so that, before the fix, the test fails fast instead of hanging/OOMing CI.hotReloadManager.TriggerOnDeltaApplied().ShouldRender() => falsecomponent is not re-rendered again by the cycle), i.e.Batchesdoes not grow without bound andUpdatedComponentDiffsstays small.Existing assets that help author the components:
src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithShouldRender.razor(aShouldRender() => falsecomponent) andRenderOnHotReload.razor, which already verify thatShouldRender() => falsecomponents are force-rendered on reload, the behavior we must keep while terminating the loop.Acceptance criteria
ShouldRender() == falseto break a synchronous re-render cycle no longer loops unboundedly during hot reload; the batch terminates.RendererTest.cscovering the cycle-under-hot-reload scenario.Repro requirements (for #67012)
This is not reproducible from a blank Blazor project; it requires a component that (a) overrides
ShouldRender()to returnfalseand (b) synchronously triggersStateHasChanged()during rendering. A minimal repro of that shape would become the regression test.Related: #67012, #59027