Skip to content

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

Merged
javiercn merged 3 commits into
mainfrom
copilot/fix-hot-reload-oome
Jun 23, 2026
Merged

Hot reload: scope ShouldRender bypass to first render per component to prevent unbounded re-render loop (OOM)#67372
javiercn merged 3 commits into
mainfrom
copilot/fix-hot-reload-oome

Conversation

Copilot AI commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

During a hot-reload metadata-update pass, ComponentBase.StateHasChanged bypassed ShouldRender() for the entire pass. Apps that rely on ShouldRender() == false to break a synchronous re-render cycle (component A renders → triggers B → B renders → triggers A → …) had that brake removed, causing an unbounded render loop that OOMs the process.

Description

Scopes the ShouldRender bypass to each component's first render in the hot-reload pass. On any subsequent StateHasChanged call within the same pass, ShouldRender() is evaluated normally, terminating the cycle. Every component still re-renders at least once (no stale UI) because the diff already force-calls SetDirectParameters top-down on every component regardless.

Motivation and Context

Apps hitting #67012 experienced memory growth to 6 GB+ and OS-level process kills after a hot reload. Root cause: IsRenderingOnMetadataUpdate was latched true for the entire synchronous pass, so every StateHasChanged call inside that pass bypassed ShouldRender, removing the only cycle-breaker in apps that relied on it.

Changes Made

  • Renderer.cs — adds HashSet<int>? _hotReloadRenderedComponentIds (lazily allocated, nulled out after the pass — zero cost on the normal render path). New internal method IsFirstHotReloadRender(int componentId) uses HashSet.Add to return true the first time a component is seen in the pass.
  • RenderHandle.cs — adds IsFirstHotReloadRender() delegating to the renderer.
  • ComponentBase.cs — tightens the bypass condition:
    // Before
    if (_hasNeverRendered || ShouldRender() || _renderHandle.IsRenderingOnMetadataUpdate)
    
    // After
    if (_hasNeverRendered || ShouldRender() || (_renderHandle.IsRenderingOnMetadataUpdate && _renderHandle.IsFirstHotReloadRender()))
    The && short-circuit ensures the HashSet is only touched during a hot-reload pass.
  • RendererTest.cs — regression test HotReload_ShouldRenderBypassScopedToFirstRender_TerminatesCyclicRenderLoop: two root components that mutually call StateHasChanged on each other from BuildRenderTree, both with ShouldRender() => false. Self-limits at 10 renders so CI fails fast without the fix rather than hanging/OOMing. Asserts each component renders exactly twice (once initial, once hot-reload forced).

How Has This Been Tested?

New regression test covers the cycle scenario directly. All 1,267 existing Microsoft.AspNetCore.Components.Tests tests continue to pass.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

…load

Fixes the OOM infinite re-render loop (issue #67371). During a hot
reload metadata-update pass, ComponentBase.StateHasChanged previously
bypassed ShouldRender() for the entire pass. For apps that rely on
ShouldRender() == false to break a synchronous re-render cycle this
removed the brake, causing an unbounded loop.

The fix:
- Renderer tracks which component IDs have already received a
  hot-reload render in the current pass via a lazily-allocated
  HashSet<int> (_hotReloadRenderedComponentIds), which is nulled out
  when the pass ends so there is no allocation in the non-hot-reload
  path.
- RenderHandle exposes IsFirstHotReloadRender() delegating to the
  renderer's new IsFirstHotReloadRender(componentId) method.
- ComponentBase.StateHasChanged gates the ShouldRender bypass on
  IsRenderingOnMetadataUpdate && IsFirstHotReloadRender(), so the bypass
  applies exactly once per component. Subsequent StateHasChanged calls
  in the same pass go through ShouldRender() normally, terminating any
  synchronous re-render cycle.

Regression test added: HotReload_ShouldRenderBypassScopedToFirstRender
_TerminatesCyclicRenderLoop verifies two root components that mutually
trigger each other's StateHasChanged in BuildRenderTree each render
exactly once during a simulated hot reload (not 10+ times until OOM).

Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix scope ShouldRender bypass to prevent unbounded re-render loop Hot reload: scope ShouldRender bypass to first render per component to prevent unbounded re-render loop (OOM) Jun 22, 2026
Copilot AI requested a review from javiercn June 22, 2026 19:20
@javiercn javiercn marked this pull request as ready for review June 22, 2026 20:07
@javiercn javiercn requested a review from a team as a code owner June 22, 2026 20:07
Copilot AI review requested due to automatic review settings June 22, 2026 20:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a hot-reload regression in Blazor rendering where ComponentBase.StateHasChanged could bypass ShouldRender() for an entire metadata-update pass, enabling synchronous cyclic re-render loops that can lead to OOM. The fix scopes the ShouldRender bypass to the first hot-reload render per component, ensuring each component refreshes at least once while still allowing ShouldRender() == false to stop cycles on subsequent renders within the same pass.

Changes:

  • Track per-pass “first hot reload render” per component via a lazily allocated HashSet<int> in Renderer, cleared after the metadata-update pass.
  • Expose a renderer-backed RenderHandle.IsFirstHotReloadRender() helper and tighten ComponentBase.StateHasChanged to bypass ShouldRender() only on a component’s first hot-reload render.
  • Add a regression test that reproduces the cyclic re-render loop scenario and asserts termination.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/Components/Components/src/RenderTree/Renderer.cs Adds per-hot-reload-pass component tracking to scope the ShouldRender bypass.
src/Components/Components/src/RenderHandle.cs Adds IsFirstHotReloadRender() hook used by ComponentBase during hot reload.
src/Components/Components/src/ComponentBase.cs Updates StateHasChanged hot-reload bypass condition to require “first render in pass”.
src/Components/Components/test/RendererTest.cs Adds regression test covering a cyclic StateHasChanged loop during hot reload.

Comment thread src/Components/Components/src/RenderHandle.cs Outdated
Comment thread src/Components/Components/test/RendererTest.cs
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@javiercn javiercn enabled auto-merge (squash) June 23, 2026 14:33
@javiercn javiercn merged commit cd6a156 into main Jun 23, 2026
25 checks passed
@javiercn javiercn deleted the copilot/fix-hot-reload-oome branch June 23, 2026 17:09
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-preview6 milestone Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants