fix(reconciler): re-render context consumers behind reference-stable child skips (#811)#812
Conversation
…child skips A component that consumes context via UseContext could stop updating when it lived inside a reference-stable subtree. When a parent re-rendered with a changed provider value but handed back the *same* child Element instance, the reconciler's shallow-equality fast paths short-circuited the update before descending into the consumer, so HasConsumedContextChanged was never consulted and the consumer kept rendering its stale context value (and stale event handlers captured the stale value). The skip could be swallowed at two independent layers, so both are now context-aware: - Reconciler.Update.cs: before taking the element-level ShallowEquals skip, decline the skip when a consumed context value in the subtree changed. - ChildReconciler.cs (UpdateCommonChild): before taking the per-child CanSkipUpdate skip, perform the same check and fall through to a real update when consumed context changed. Both checks are gated by ContextScope.HasActiveValues so the subtree walk is skipped entirely when no provider is active, and driven by the new Reconciler.HasConsumedContextChangedInSubtree(control), which tests the control's ComponentNode and recurses through ForEachReactorChildControl. Adds a self-test regression fixture (Issue811ContextConsumerSkipFixtures) covering mount, provider toggle re-render, and current-vs-stale context use from an event handler, registered in SelfTestFixtureRegistry. Validation: - Focused host selftest Issue811_ReferenceStableChildSkip_ContextConsumerRerenders: 8/8 checks pass. - Reactor.Tests slice (ChildReconcilerStructuralSkip, ContextSystemSelfHost, MemoizationSelfHost, ComponentModelIntegration): 56 passed, 0 failed. - Full Reactor.SelfTests batch: passed. Fixes microsoft#811
…-context-consumer-skip # Conflicts: # tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs
There was a problem hiding this comment.
Pull request overview
Fixes a reconciler correctness bug where UseContext consumers inside a reference-stable child subtree could miss pure context updates due to early skip fast-paths, leaving UI and captured event handlers stale.
Changes:
- Make both element-level and per-child skip paths context-aware by consulting subtree context-consumer invalidation before skipping.
- Add
ContextScope.HasActiveValues/Reconciler.HasActiveContextValuesgating and aHasConsumedContextChangedInSubtree(UIElement)traversal helper. - Add a self-test regression fixture for Issue #811 and register it in
SelfTestFixtureRegistry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs | Registers the new Issue #811 self-test fixture name and constructor mapping. |
| tests/Reactor.AppTests.Host/SelfTest/Fixtures/Issue811ContextConsumerSkipFixtures.cs | New self-test reproducer validating context-driven rerender behind reference-stable child skips. |
| src/Reactor/Core/Reconciler.Update.cs | Declines element-level shallow skip when a consumed context value changed in the subtree. |
| src/Reactor/Core/Reconciler.cs | Exposes HasActiveContextValues and adds HasConsumedContextChangedInSubtree traversal helper. |
| src/Reactor/Core/ContextScope.cs | Adds HasActiveValues to allow gating context-related subtree checks. |
| src/Reactor/Core/ChildReconciler.cs | Declines per-child CanSkipUpdate skip when a consumed context value changed in the existing child subtree. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bool subtreeContextChanged = false; | ||
| if (HasActiveContextValues) | ||
| subtreeContextChanged = HasConsumedContextChangedInSubtree(control); | ||
|
|
||
| if (Element.ShallowEquals(oldEl, newEl) | ||
| && Element.ModifiersEqual(oldModifiers, modifiers) | ||
| && oldEl.HasCallbacks == newEl.HasCallbacks | ||
| && !ForceRenderThroughWrapper(newEl) | ||
| && !IsOnDirtyAncestorPath(control)) | ||
| && !IsOnDirtyAncestorPath(control) | ||
| && !subtreeContextChanged) |
There was a problem hiding this comment.
Good catch, fixed in f7f2485. I folded the walk into the skip predicate as the last && term: !(HasActiveContextValues && HasConsumedContextChangedInSubtree(control)). Now short-circuiting means the recursive subtree walk only runs after the cheap structural gates (ShallowEquals/ModifiersEqual/etc.) all pass, so a non-skippable element never pays for it, and it's skipped entirely when no provider is on the stack.
Note this only covered the element-level Update path. The per-child arm in ChildReconciler already gates the walk behind CanSkipUpdate + HasActiveContextValues before the children.Get(i) fetch, so it's fine. The deeper follow-up is that the gate keys off HasActiveValues (any provider present) rather than whether a provided value actually changed this render, so stable subtrees still get walked even when no context changed. Worth a follow-up using a context version signal.
Fold HasConsumedContextChangedInSubtree into the element-level skip && chain so it only runs after the cheap structural gates pass. Non-skippable elements (ShallowEquals false) no longer pay for the recursive subtree walk on the hot Update path; HasActiveContextValues short-circuits it away when no provider is on the stack. Addresses Copilot PR review comment on microsoft#812. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
azchohfi
left a comment
There was a problem hiding this comment.
Review summary — context-consumer re-render fix (#811)
Thanks for tackling this. The core mechanism — declining the skip when a consumed context changed, via HasConsumedContextChangedInSubtree — is sound, and the primary case (a reference-stable component sibling under .Provide(...)) is fixed and covered by the new selftest.
My main concern is that the fix is incomplete for keyed lists, which I'd treat as a blocker. Details are in the inline comments; summarizing here:
Blocking
- Keyed + hint skip paths bypass the new check. The context-aware decline only lives in the positional
UpdateCommonChildarm and the element-level shallow skip. The keyed prefix (ChildReconciler.cs~L313) and suffix (~L362) arms, and theChildDiffHintsfast-path (~L111–143), still skip onCanSkipUpdatewith no context check — so a keyed stable child (grid rows, keyed list items) that consumes context still goes stale on a provider change. That's the same #811 bug via the keyed path. I'd centralize the decision into oneShouldDeclineSkip(oldEl, newEl, control)helper shared by all four skip sites — that closes the gap in one place and lets the positional arm drop the one-offgoto.
Non-blocking
- Traversal blind spot.
ForEachReactorChildControldoesn't coverSplitView(Pane/Content) orViewbox(Child), both real Reactor child hosts — consumers under them inside a stable wrapper are missed. - Coarse gate / perf. The walk is gated on
HasActiveValues(any provider present), not on whether a value actually changed, so stable subtrees get walked speculatively on every skip while a provider is active. A_version-derived change signal would avoid it. (The earlier hot-path ordering nit from the Copilot review is already handled inf7f2485d.) - Test coverage. Add keyed, deeper-nested (stable non-component container), and negative (unchanged-context ⇒ no re-render) selftest variants.
- CHANGELOG. No
### Fixedentry for #811 (user-visible bug).
Happy to pair on the ShouldDeclineSkip refactor if useful.
| if (reconciler.HasActiveContextValues) | ||
| { | ||
| existingControl = children.Get(i); | ||
| if (reconciler.HasConsumedContextChangedInSubtree(existingControl)) | ||
| goto perform_update; |
There was a problem hiding this comment.
This context-aware skip decline only lives here in the positional UpdateCommonChild arm, so the fix is incomplete for keyed lists. The other skip sites in this file don't have it:
- keyed prefix skip (~L313) and keyed suffix skip (~L362) still
continueonCanSkipUpdatewith noHasConsumedContextChangedInSubtreecheck, so a keyed stable child (grid rows, keyed list items) that consumes context still goes stale on a provider change — the exact Bug: pure context changes can be lost behind reference-stable child skip paths #811 bug via the keyed path; - the
ChildDiffHintsfast-path (~L111-143) skips untouched indices wholesale and only callsUpdateCommonChildfor changed indices, so it bypasses the check too.
The rule now exists in three shapes (this goto perform_update, the keyed continue, and the && chain in Reconciler.Update.cs). I'd pull it into a single ShouldDeclineSkip(oldEl, newEl, control) helper used by all four sites — that closes the keyed/hint gaps in one place and lets this arm drop the one-off goto. Please add a keyed selftest variant to lock it in.
| return context.DefaultValueBoxed; | ||
| } | ||
|
|
||
| internal bool HasActiveValues => _stack.Count > 0; |
There was a problem hiding this comment.
HasActiveValues gates the subtree walk on any provider being on the stack, not on whether a provided value actually changed this render. With a provider near the app root (the common case), every reference-stable skip now pays a recursive HasConsumedContextChangedInSubtree walk even when no context changed — a real cost on the hot skip-floor.
There's already a _version here that bumps on every push/pop. Consider deriving a cheaper change signal from it (or a per-node "consumed-context version at last render") so stable subtrees are only walked when a provided value actually changed, rather than whenever any provider is present.
| internal bool HasConsumedContextChangedInSubtree(UIElement control) | ||
| { | ||
| if (_componentNodes.TryGetValue(control, out var node) | ||
| && HasConsumedContextChanged(node)) | ||
| return true; | ||
|
|
||
| bool found = false; | ||
| ForEachReactorChildControl(control, child => |
There was a problem hiding this comment.
This walk is only as complete as ForEachReactorChildControl, which covers Panel / ItemsRepeater / Border / ScrollViewer / UserControl / ContentControl but not SplitView (Pane/Content) or Viewbox (Child) — both real Reactor child hosts. A context consumer under one of those inside a reference-stable wrapper will be missed and stay stale, so the fix has a blind spot.
Better to drive traversal off the descriptor child metadata / GetCurrentChild accessors so every V1 child-hosting strategy is covered, rather than an ad-hoc control-type switch. (Unmount is unaffected — it uses strategy-driven teardown — but this walk and the nav-lifecycle traversals that reuse the helper share the gap.)
| stableOverlay) | ||
| .Provide(InteractiveCtx, interactive); |
There was a problem hiding this comment.
Solid repro for the positional/unkeyed case. To cover the rest of the fix, worth adding three variants:
- Keyed: put
.Key(...)onstableOverlayso it routes through the keyed prefix/suffix skip arms (currently uncovered — see the ChildReconciler comment). - Nested: wrap the overlay in a reference-stable non-component container (a stable
Border/ScrollViewer) so the recursive walk inHasConsumedContextChangedInSubtreeis actually exercised — here the consumer is a direct child, one level deep. - Negative: re-render from unrelated state with the provider value unchanged and assert
probe.RenderCountdoes not increase, so the coarseHasActiveValuesgate can't regress into over-rendering.
Bug: pure context changes lost behind reference-stable child skip paths
Fixes #811.
Root cause
A
UseContextconsumer nested inside a reference-stable subtree stoppedre-rendering on pure context changes. When a parent re-rendered with a changed
provider value but returned the same child
Elementinstance, thereconciler's shallow-equality fast paths short-circuited before descending
into the consumer, so
HasConsumedContextChangedwas never consulted. Theconsumer kept rendering the stale context value, and event handlers it created
captured that stale value.
The skip was reachable at two independent layers, so a single-site fix was
insufficient (verified: removing either guard re-breaks the repro).
Fix
Both skip fast paths are now context-aware:
Reconciler.Update.cs— before the element-levelShallowEqualsskip,decline the skip when a consumed context value changed in the subtree.
ChildReconciler.cs(UpdateCommonChild) — before the per-childCanSkipUpdateskip, run the same check and fall through to a real update.Both are gated by the new
ContextScope.HasActiveValuesso no subtree walkhappens when no provider is active, and are driven by
Reconciler.HasConsumedContextChangedInSubtree(control), which checks thecontrol's
ComponentNodeand recurses viaForEachReactorChildControl.Tests
New self-test regression fixture
Issue811ContextConsumerSkipFixtures.ReferenceStableChildSkip_ContextConsumerRerenders(registered in
SelfTestFixtureRegistry) covering mount, provider-togglere-render, and current-vs-stale context use from an event handler.
Validation
Issue811_ReferenceStableChildSkip_ContextConsumerRerenders: 8/8 checks pass.Reactor.Testsslice (ChildReconcilerStructuralSkip,ContextSystemSelfHost,MemoizationSelfHost,ComponentModelIntegration): 56 passed / 0 failed.Reactor.SelfTestsbatch: passed.