Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/Reactor/Core/ChildReconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,17 @@ private static void UpdateCommonChild(
// of the COM fetch a membership check would cost on the hot skip-floor.
var oldEl = oldChildren[i];
var newEl = newChildren[i];
UIElement? existingControl = null;
if (Element.CanSkipUpdate(oldEl, newEl)
&& !reconciler.ForceRenderThroughWrapper(newEl))
{
if (reconciler.HasActiveContextValues)
{
existingControl = children.Get(i);
if (reconciler.HasConsumedContextChangedInSubtree(existingControl))
goto perform_update;
Comment on lines +220 to +224

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 continue on CanSkipUpdate with no HasConsumedContextChangedInSubtree check, 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 ChildDiffHints fast-path (~L111-143) skips untouched indices wholesale and only calls UpdateCommonChild for 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.

}

reconciler.DebugElementsSkipped++;
// Refresh Tag when the element carries callbacks. The skip short-
// circuits Update, so without this the event trampoline keeps
Expand All @@ -229,7 +237,7 @@ private static void UpdateCommonChild(
if ((newEl.HasCallbacks
|| Reconciler.HasGestureOrDragSlots(newEl.Modifiers)
|| Reconciler.HasGestureOrDragSlots(oldEl.Modifiers))
&& children.Get(i) is FrameworkElement fe)
&& (existingControl ??= children.Get(i)) is FrameworkElement fe)
{
if (newEl.HasCallbacks)
Reconciler.SetElementTag(fe, newEl);
Expand All @@ -238,9 +246,10 @@ private static void UpdateCommonChild(
return;
}

perform_update:
if (reconciler.CanUpdate(oldEl, newEl))
{
var existingControl = children.Get(i);
existingControl ??= children.Get(i);
var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender);
if (replacement is not null)
{
Expand Down
2 changes: 2 additions & 0 deletions src/Reactor/Core/ContextScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,7 @@ internal T Read<T>(Context<T> context)
return context.DefaultValueBoxed;
}

internal bool HasActiveValues => _stack.Count > 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 long Version => _version;
}
9 changes: 8 additions & 1 deletion src/Reactor/Core/Reconciler.Update.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,18 @@ public sealed partial class Reconciler
// in UpdateXxx never gets to attach the WinRT event. If presence
// changes, force Update so EnsureXxxWiring (poolable) or the diff-based
// null→non-null checks (non-poolable) can subscribe.
// Context-consumer subtree check is placed LAST in the skip predicate so
// the recursive HasConsumedContextChangedInSubtree walk only runs after the
// cheap structural gates pass (via && short-circuiting). Elements that can't
// be skipped (e.g. ShallowEquals false) never pay for the subtree traversal,
// and HasActiveContextValues short-circuits it away entirely when no provider
// is on the stack.
if (Element.ShallowEquals(oldEl, newEl)
&& Element.ModifiersEqual(oldModifiers, modifiers)
&& oldEl.HasCallbacks == newEl.HasCallbacks
&& !ForceRenderThroughWrapper(newEl)
&& !IsOnDirtyAncestorPath(control))
&& !IsOnDirtyAncestorPath(control)
&& !(HasActiveContextValues && HasConsumedContextChangedInSubtree(control)))
{
DebugElementsSkipped++;
// Refresh Tag so the event trampoline dispatches into the new element's
Expand Down
23 changes: 23 additions & 0 deletions src/Reactor/Core/Reconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,8 @@ internal IDisposable PushContextDisposable<T>(Context<T> context, T value)
// without exposing the traversal stack itself.
internal T ReadContext<T>(Context<T> context) => _contextScope.Read(context);

internal bool HasActiveContextValues => _contextScope.HasActiveValues;

/// <summary>
/// Spec 047 §14 Phase 1 (1.6) — push a stagger scope for child enter
/// transitions. Returns an <see cref="IDisposable"/> that pops the
Expand Down Expand Up @@ -1908,6 +1910,27 @@ private bool HasConsumedContextChanged(ComponentNode node)
return false;
}

internal bool HasConsumedContextChangedInSubtree(UIElement control)
{
if (_componentNodes.TryGetValue(control, out var node)
&& HasConsumedContextChanged(node))
return true;

bool found = false;
ForEachReactorChildControl(control, child =>
Comment on lines +1913 to +1920

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

{
if (HasConsumedContextChangedInSubtree(child))
{
found = true;
return false;
}

return true;
});

return found;
}

/// <summary>
/// Calls ShouldUpdate(oldProps, newProps) on a Component&lt;TProps&gt; via interface dispatch.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.AppTests.Host.SelfTest;
using static Microsoft.UI.Reactor.Factories;

namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;

/// <summary>
/// Issue #811 — a reference-stable child subtree that consumes context must still
/// re-render when the provided context value changes.
///
/// The reproducer is intentionally minimal: a parent owns internal interactive
/// state, provides it via <c>.Provide(...)</c>, and re-emits the SAME overlay
/// element instance on every render. The overlay consumes that context to derive
/// both visible text and a click action. If child-skip short-circuits before the
/// reconciler descends into the consumer, the label stays stale and the click path
/// keeps dispatching the old action shape.
/// </summary>
internal static class Issue811ContextConsumerSkipFixtures
{
private static readonly Context<bool> InteractiveCtx = new(true);

private sealed class Probe
{
public int RenderCount;
public string? LastAction;
}

private sealed record OverlayProps(Probe Probe);

private sealed class OverlayConsumer : Component<OverlayProps>
{
public override Element Render()
{
Props.Probe.RenderCount++;
bool interactive = UseContext(InteractiveCtx);

return VStack(
TextBlock(interactive ? "Lock interactivity" : "Unlock interactivity"),
Button("Invoke overlay action", () =>
Props.Probe.LastAction = interactive ? "lock" : "unlock"));
}
}

internal sealed class ReferenceStableChildSkip_ContextConsumerRerenders(Harness h)
: SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var probe = new Probe();
var stableOverlay = Component<OverlayConsumer, OverlayProps>(new OverlayProps(probe));

var host = H.CreateHost();
host.Mount(ctx =>
{
var (interactive, setInteractive) = ctx.UseState(true);

return VStack(
TextBlock(interactive ? "surface:on" : "surface:off"),
Button("Toggle interactive", () => setInteractive(!interactive)),
stableOverlay)
.Provide(InteractiveCtx, interactive);
Comment on lines +61 to +62

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid repro for the positional/unkeyed case. To cover the rest of the fix, worth adding three variants:

  1. Keyed: put .Key(...) on stableOverlay so it routes through the keyed prefix/suffix skip arms (currently uncovered — see the ChildReconciler comment).
  2. Nested: wrap the overlay in a reference-stable non-component container (a stable Border/ScrollViewer) so the recursive walk in HasConsumedContextChangedInSubtree is actually exercised — here the consumer is a direct child, one level deep.
  3. Negative: re-render from unrelated state with the provider value unchanged and assert probe.RenderCount does not increase, so the coarse HasActiveValues gate can't regress into over-rendering.

});

await Harness.Render();

H.Check("Issue811_Mount_SurfaceOn", H.FindText("surface:on") is not null);
H.Check("Issue811_Mount_LabelLock", H.FindText("Lock interactivity") is not null);
H.Check("Issue811_Mount_OverlayRenderedOnce", probe.RenderCount == 1);

H.ClickButton("Toggle interactive");
await Harness.Render();

H.Check("Issue811_Toggle_SurfaceOff", H.FindText("surface:off") is not null);
H.Check("Issue811_Toggle_LabelUpdated", H.FindText("Unlock interactivity") is not null);
H.Check("Issue811_Toggle_OverlayRerendered", probe.RenderCount >= 2);

H.ClickButton("Invoke overlay action");
await Harness.Render();

H.Check("Issue811_ActionUsesCurrentContext", probe.LastAction == "unlock");
H.Check("Issue811_ActionDidNotUseStaleContext", probe.LastAction != "lock");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ internal static class SelfTestFixtureRegistry
"StructuralSkip_ThemeRangeParity",
"UseMemoCells_BaseReThemeOnAncestorToggle",
"StructuralSkip_HotReloadWrapperReRender",
"Issue811_ReferenceStableChildSkip_ContextConsumerRerenders",
"ThemeBindingsSkip_ImmediateAncestorToggleSelfHeals",
"ThemeBindingsSkip_InheritedAncestorToggleSelfHeals",
"ThemeBindingsSkip_WholeListEarlyOutToggleSelfHeals",
Expand Down Expand Up @@ -1867,6 +1868,7 @@ internal static class SelfTestFixtureRegistry
"StructuralSkip_ThemeRangeParity" => new StructuralSkipFixtures.ThemeRangeParity(harness),
"UseMemoCells_BaseReThemeOnAncestorToggle" => new UseMemoCellsThemeReResolveFixtures.BaseMemoCellsReThemeOnAncestorToggle(harness),
"StructuralSkip_HotReloadWrapperReRender" => new StructuralSkipFixtures.HotReloadWrapperReRender(harness),
"Issue811_ReferenceStableChildSkip_ContextConsumerRerenders" => new Issue811ContextConsumerSkipFixtures.ReferenceStableChildSkip_ContextConsumerRerenders(harness),
"ThemeBindingsSkip_ImmediateAncestorToggleSelfHeals" => new ThemeBindingsSkipSelfHealFixtures.ImmediateAncestorToggleSelfHeals(harness),
"ThemeBindingsSkip_InheritedAncestorToggleSelfHeals" => new ThemeBindingsSkipSelfHealFixtures.InheritedAncestorToggleSelfHeals(harness),
"ThemeBindingsSkip_WholeListEarlyOutToggleSelfHeals" => new ThemeBindingsSkipSelfHealFixtures.WholeListEarlyOutToggleSelfHeals(harness),
Expand Down
Loading