Skip to content

Hot reload: scope ShouldRender bypass to first render per component to prevent unbounded re-render loop (OOM) #67371

Description

@javiercn

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:

  1. 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).

  2. 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:

  1. 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.
  2. Trigger an initial render, then hotReloadManager.TriggerOnDeltaApplied().
  3. 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

Metadata

Metadata

Labels

area-blazorIncludes: Blazor, Razor Components

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions