Skip to content

Fix dynamic ItemsHost reconciliation for generated control wrappers - #1363

Open
kusutoriクスとり (kusutori) wants to merge 9 commits into
microsoft:mainfrom
kusutori:fix/dynamic-items-host
Open

kusutoriクスとり (kusutori) wants to merge 9 commits into
microsoft:mainfrom
kusutori:fix/dynamic-items-host

Conversation

@kusutori

@kusutori kusutoriクスとり (kusutori) commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fix flat ItemsHost reconciliation used by generated WCT SettingsExpander wrappers. Dynamic cards now notify the repeater without rebuilding existing native children, preserving component state, focus, input drafts and animation players.

  • Add optional ItemsHost.SetCollection; generate it only for a public writable IList<object> property. At mount, replace a non-notifying managed sink with a stable observable collection, while retaining native/read-only sinks and existing notifying collections.
  • Share incremental reconciliation between descriptor and hand-coded-handler paths: reconcile actual mounted controls, match keyed Elements across moves, descend into unchanged Element sources, and detach before unmount/pool reuse. Observable moves do not emit Reset.
  • Generate property-owned terminal teardown for [WrapElementSlot] without replacing the descriptor author's OnUnmount or clearing undeclared constructor defaults. Retain the existing two-callback ImperativeBridged overload.
  • Generate WinRT exposure for the framework's observable list across the indirect Toolkit-to-ItemsRepeater Native AOT boundary. Update API indexes and contributor guide templates/generated pages.

Linked issue / spec

Fixes #1362

Test plan

Local validation on Windows x64 (framework SDK 10.0.400; consuming app SDK 11.0.100-rc.1.26425.128):

  • Core, generator and live selftest host Release builds: no warnings or errors.
  • dotnet test tests/Reactor.Tests --no-build -c Release -p:Platform=x64 --filter-class '*WrapperGeneratorTests*': 46 passed. The new writable-Items assertion fails when the generator DLL is replaced with the official preview.16 generator, and passes after restoring the fix.
  • dotnet run --project tests/Reactor.AppTests.Host --no-build -c Release -p:Platform=x64 -- --self-test --filter ItemsHost_Observable: 15 assertions passed, including actual repeater realization after insertion/removal, keyed identity/state retention, child-owned state, enabled-state updates, Move/no Reset, exactly-once cleanup and Content/HeaderIcon detach.
  • Pack the core with the updated generator and reference it from Tonarink. Remove the handwritten SettingsExpander adapter: the generated wrapper builds and works.
  • Consuming-app focused WinUI checks: 3 passed (Expander identity/header/descendant updates and path-override draft/validation behavior). Two existing unrelated CsWinRT1032 errors in ReceivePageTests were excluded for this targeted build; they were not changed or counted as passes.
  • Publish the consuming app with Native AOT for win-x64 and deploy under an isolated loose development-package identity. Actual UIA verifies settings expansion, dynamic add/remove, enabled-state updates, draft retention across navigation, invalid-path validation and hover lid animation. Deployed/published executable hashes match; CLR header RVA/size are both zero. Diagnostic package/process removed afterward.
  • Doc pipeline compile for control-reconciler-protocol and extending-reactor-controls: passed (existing screenshots retained).
  • Separate dotnet restore Reactor.slnx: passed.
  • dotnet build Reactor.slnx --no-restore -c Release -p:SkipSignaturesGen=true -m:1 -nodereuse:false: passed, 0 errors, including the packaged host that links the fixture. Two WIN2D0001 warnings report AnyCPU references in Reactor.Devtools / Chat.UI; no warning suppressions added.
  • Full unit/selftest/packaged/E2E suites: not run locally; the results above are focused checks, not a full-suite claim. CI still needs to validate the complete change.

Risk / breaking changes

Two additive public APIs: ItemsHost.SetCollection and the three-callback ImperativeBridged overload. Existing signatures are retained. New generated code needs the matching runtime, both shipped in the core package.

Flat-item behavior intentionally changes from wholesale rebuild to incremental reconciliation: Element identity is keyed or positional; ItemEquals applies to raw values, not as a gate that skips Element descendants. The new collection setter must not target concrete native ItemCollection or read-only properties.

Generated secondary slots now receive terminal teardown before descriptor OnUnmount; custom descriptor callbacks must not unmount the same generated slot again. Verification covers x64 and loose package identity, not ARM64 or signed MSIX installation/upgrade.

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.

Thanks for this. Pulling the two ItemsHost copies into one shared helper, reconciling incrementally with a stable observable sink, and giving generated slots real teardown are all good changes. Putting teardown on each property entry also looks like the right layer: WithUnmount would replace an author's own hook, and NamedSlot isn't available while Items holds the ItemsHost.

I'm leaving this as a comment for now, not an approval. I didn't find anything blocking, but a few things are worth addressing first (details inline):

  • Teardown order. Items and generated slots are removed from the tree before UnmountChild runs. The engine expects unmount first, while the control is still parented (for connected-animation snapshots, among other things). This is the main one.
  • Hot Reload. The CanUpdate pre-check bypasses ReconcileV1Child's Hot Reload state migration.
  • Performance for raw-item lists. Lists with no Element items lost their unchanged-list early return and now pay an O(n²) match plus allocations on every render.
  • ItemEquals semantics. A match now keeps the old instance in the sink. Worth documenting, or changing.
  • Test gaps. Always visiting Element items, unkeyed/type-change/pooled items, raw items on a native ItemCollection, the HasUnmount rooting, and CanAssignItems rejection all lack a test that would fail if the code were reverted.

Not tied to a changed line:

  • plugins/reactor/skills/reactor-wrapping/SKILL.md (the [WrapElementSlot] row) still describes slots as mount + update only. It should mention the new generated detach/unmount teardown and that the control's own default is left alone when no slot element is declared.
  • The ItemsHost mount/update branches in V1HandlerAdapter (hand-coded handlers) aren't exercised by any test: DescriptorHandler.Children hides ItemsHost, so the new fixture only goes through the descriptor path. There's no in-repo hand-coded handler that exposes ItemsHost today, so this is low priority, but a minimal registered handler fixture would cover the extra rooting in Bind.

Things I checked that looked fine: both reactor.api.txt copies are byte-identical; the new SettingsControls reference is limited to the test hosts and its version is pinned centrally; generated code uses only public API; the generator changes are netstandard2.0-safe; the new runtime code is AOT/trim-safe (no reflection; GeneratedWinRTExposedExternalType covers the observable collection); and the generated guides match their templates.

Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs Outdated
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs Outdated
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs
Comment thread src/Reactor/Core/V1Protocol/ChildrenStrategy.cs
Comment thread src/Reactor.Wrappers.Generator/WrapperGenerator.cs
Comment thread src/Reactor/Core/V1Protocol/Descriptor/DescriptorHandler.cs
Comment thread src/Reactor/Core/V1Protocol/Descriptor/ControlDescriptor.cs Outdated
Comment thread tests/Reactor.AppTests.Host/SelfTest/Fixtures/ItemsHostReconciliationFixture.cs Outdated
Comment thread tests/Reactor.Tests/WrappersGenerator/WrapperGeneratorTests.cs
@azchohfi

Copy link
Copy Markdown
Collaborator

Perf: before vs after (main @ aed6870 vs PR @ e74a652)

Measured locally on an i9-14900K. main and the PR were interleaved on the same machine with a fresh process per round, and warm-up runs were discarded.

TL;DR

  • The existing perf suite shows no regression. None of the standard stress or micro benchmarks reach ItemsHost, so they can't measure the main change.
  • A targeted benchmark shows a clear trade-off. When the items list changes, updates are 20–500× faster, because there is no more clear-and-rebuild. When the parent re-renders with an unchanged list, the ComboBox update costs 5–12× more time and allocates about 4.4 KB per update for 100 items. The second case is the common one.

1. Standard suite (Run-PerfBenchmark.ps1, 3 measured runs each)

Harness Renders/s Reconcile ms Diff ms Memory MB
Stocks grid, 50% mutation 5.06 → 5.44 36.7 → 34.0 32.2 → 30.2 277.8 → 274.1
Stocks grid, skip floor (0%) 10.32 → 10.44 11.2 → 11.6 10.2 → 10.7 253.6 → 254.0
Keyed list 15.72 → 15.46 6.1 → 7.6 6.0 → 7.5 196.3 → 186.6
Flex 3.37 → 3.42 47.1 → 47.8 42.1 → 43.2 305.4 → 307.0
DataGrid 7.31 → 7.27 44.0 → 44.2 43.9 → 44.1 388.9 → 389.0

All of these are within run-to-run noise; the keyed-list reconcile numbers swung 6–10 ms within each side, and this PR doesn't touch that path. The reconciler micro-suite (17 benchmarks, 4 paired rounds) also shows no timing change; every paired range includes zero. One deterministic allocation delta did show up:

  • ToggleSwitch mount + unmount: +48 B per op (4,439 → 4,487). The likely cause is DescriptorHandler.Unmount, which now runs foreach over _descriptor.Properties. That property is typed IReadOnlyList<…>, so each unmount of every descriptor-backed control boxes an enumerator. An indexed for loop fixes it. Caching the Properties.Any(p => p.HasUnmount) result once per descriptor would also take that scan off the mount path.

2. Targeted ItemsHost benchmark (ComboBox, 1 warm-up + 8 paired rounds, 2,000 updates per round)

Each update re-renders the ComboBox element and calls UpdateChild.

Scenario main PR Change Alloc / update
100 element items, same array, only the host's Margin changes 4.8 µs 58.8 µs 12.3× slower 1.4 KB → 5.6 KB
100 element items, new but equal array (typical Render()) 15.1 µs 81.3 µs 5.4× slower 0.2 KB → 4.5 KB
100 element items, one item changed 19,972 µs 41.0 µs ~490× faster 42.5 KB → 4.5 KB
100 strings, new but equal array 4.0 µs 24.9 µs 6.2× slower 0.2 KB → 4.4 KB
1,000 strings, new but equal array 25.5 µs 308.9 µs 12.1× slower 0.2 KB → 41.3 KB
100 strings, one string changed 757.8 µs 37.6 µs 20× faster 6.7 KB → 4.5 KB

Paired per-round ranges excluded zero in every row.

Where the unchanged-list cost comes from. ItemsHostReconciliation.Bind now runs in full on every update. Each run:

  • allocates a Dictionary, a bool[], a new List and one Item record per entry;
  • matches raw items with a linear scan of previous, which is O(n²);
  • reads the native ItemCollection across WinRT for every slot.

The old early return (same reference, or element-wise equal) skipped all of that.

Suggestions. None of these is blocking: the absolute cost is about 55 µs per parent render for a 100-item ComboBox.

  • Raw (non-Element) items: restore an early return when the list is the same reference or element-wise equal. No component state lives under raw items, so the "always descend" rule doesn't apply.
  • Element items: keep descending, but skip the collection-diff pass and avoid rebuilding next when every child matched in place.
  • Raw-item matching: try j == i first before the linear scan, so an in-order list stays O(n).
  • Teardown: use an indexed loop in DescriptorHandler.Unmount to remove the 48 B per unmount.
Benchmark source (drop into tests/perf_bench/PerfBench.ControlModel/Benches/ and add the six types to BenchCatalog.All)

Note: ListView(...) goes through the hand-coded ListViewHandler, not ItemsHost, so the benchmark uses ComboBox for both the Element and raw-string cases. Class names still say ListView, but the bodies use ComboBox.

// Ad-hoc PR #1363 perf probe — NOT committed. Exercises the ItemsHost update path
// (ListView = Element items, ComboBox = raw string items). Each iteration re-renders
// the host with a fresh parent element so the handler's Update path always runs.
using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Xaml;
using PerfBench.ControlModel.Variants;
using static Microsoft.UI.Reactor.Factories;

namespace PerfBench.ControlModel.Benches;

public abstract class ItemsHostBenchBase : IBench
{
    public abstract string Id { get; }
    public abstract string Name { get; }
    protected abstract Element Build(int iteration);

    public void RunOne(BenchVariant variant, BenchContext ctx)
    {
        if (ctx.Scratch is not Fixture f)
        {
            var el = Build(0);
            var ui = ctx.Reconciler.Mount(el, NoOp)!;
            ctx.Parent.Children.Add(ui);
            ctx.Scratch = new Fixture { Element = el, Control = ui };
            return;
        }
        var next = Build(ctx.Iteration + 1);
        ctx.Reconciler.UpdateChild(f.Element, next, f.Control, NoOp);
        f.Element = next;
    }

    private sealed class Fixture
    {
        public Element Element = null!;
        public UIElement Control = null!;
    }

    protected static Element[] TextItems(int n, int changedIndex = -1)
    {
        var a = new Element[n];
        for (int i = 0; i < n; i++)
            a[i] = TextBlock(i == changedIndex ? "changed" : "item " + i);
        return a;
    }

    protected static string[] StringItems(int n, int changedIndex = -1)
    {
        var a = new string[n];
        for (int i = 0; i < n; i++)
            a[i] = i == changedIndex ? "changed" : "item " + i;
        return a;
    }

    private static readonly Action NoOp = static () => { };
}

/// <summary>ComboBox ItemElements, 100 Element items, same array every render; only a host property
/// (Margin) toggles, so the host updates but its list is untouched.</summary>
public sealed class IH1_ListViewSameArray : ItemsHostBenchBase
{
    public override string Id => "IH1";
    public override string Name => "ComboBoxEl100_SameItemsArray";
    private static readonly Element[] Items = TextItems(100);
    protected override Element Build(int iteration) => ComboBox(Items, default, null).Margin(iteration & 1);
}

/// <summary>ComboBox ItemElements, 100 Element items, fresh-but-equal element instances every render (typical Render()).</summary>
public sealed class IH2_ListViewEqualFresh : ItemsHostBenchBase
{
    public override string Id => "IH2";
    public override string Name => "ComboBoxEl100_EqualFreshItems";
    private static readonly Element[] A = TextItems(100);
    private static readonly Element[] B = TextItems(100);
    protected override Element Build(int iteration) => ComboBox((iteration & 1) == 0 ? A : B, default, null);
}

/// <summary>ComboBox ItemElements, 100 Element items, one item's text toggles every render.</summary>
public sealed class IH3_ListViewOneChanged : ItemsHostBenchBase
{
    public override string Id => "IH3";
    public override string Name => "ComboBoxEl100_OneItemChanged";
    private static readonly Element[] A = TextItems(100);
    private static readonly Element[] B = TextItems(100, changedIndex: 50);
    protected override Element Build(int iteration) => ComboBox((iteration & 1) == 0 ? A : B, default, null);
}

/// <summary>ComboBox, 100 string items, fresh-but-equal arrays every render.</summary>
public sealed class IH4_ComboBoxEqualStrings100 : ItemsHostBenchBase
{
    public override string Id => "IH4";
    public override string Name => "ComboBox100_EqualStrings";
    private static readonly string[] A = StringItems(100);
    private static readonly string[] B = StringItems(100);
    protected override Element Build(int iteration) => ComboBox((iteration & 1) == 0 ? A : B);
}

/// <summary>ComboBox, 1000 string items, fresh-but-equal arrays every render (raw-item matching scale).</summary>
public sealed class IH5_ComboBoxEqualStrings1000 : ItemsHostBenchBase
{
    public override string Id => "IH5";
    public override string Name => "ComboBox1000_EqualStrings";
    private static readonly string[] A = StringItems(1000);
    private static readonly string[] B = StringItems(1000);
    protected override Element Build(int iteration) => ComboBox((iteration & 1) == 0 ? A : B);
}

/// <summary>ComboBox, 100 string items, one string toggles every render.</summary>
public sealed class IH6_ComboBoxOneChanged : ItemsHostBenchBase
{
    public override string Id => "IH6";
    public override string Name => "ComboBox100_OneStringChanged";
    private static readonly string[] A = StringItems(100);
    private static readonly string[] B = StringItems(100, changedIndex: 50);
    protected override Element Build(int iteration) => ComboBox((iteration & 1) == 0 ? A : B);
}

Run: PerfBench.ControlModel.exe --test IH1 IH2 IH3 IH4 IH5 IH6 --variant Reactor --reps 1 --iterations 2000 --headless --out <file>, alternating main and PR processes per round.

@azchohfi

Copy link
Copy Markdown
Collaborator

Following up on the review with perf numbers and an explanation of the CI failures.

Performance: main vs this PR

I compared this PR's head (e74a652) against its merge-base on main (aed6870) on one machine, alternating the two builds run by run. I used tests/stress_perf/ci/Run-PerfBenchmark.ps1 at a 50% update rate with 10 s runs, one discarded warm-up run, and three measured runs per side. The keyed-list result was noisy on the first pass (main alone ranged from 6.0 to 10.6 ms), so I reran that harness with 10 measured runs per side. The table uses the rerun.

Caveat: none of the existing stress harnesses render a control backed by ItemsHost. These numbers cover the shared paths the PR touches (descriptor mount/unmount and the reconciler), not the new items reconciliation itself.

Harness Renders/sec Avg reconcile (ms) Avg memory (MB) Allocated per render
StocksGrid, 50% updates 5.06 → 5.44 (+7.5%) 36.7 → 34.0 (−7.3%) 277.8 → 274.1 (−1.3%) −2.2%
StocksGrid, low-load floor 10.32 → 10.44 (+1.2%) 11.2 → 11.6 (+3.6%) 253.6 → 254.0 (+0.1%) −0.3%
Keyed list (10 runs) 15.74 → 15.74 (0.0%) 5.63 → 5.68 (+0.9%) 188.4 → 189.7 (+0.7%) +0.2%
Flex layout 3.37 → 3.42 (+1.4%) 47.1 → 47.9 (+1.5%) 305.4 → 307.1 (+0.6%) +1.4%
Data grid 7.31 → 7.27 (−0.5%) 44.0 → 44.2 (+0.4%) 388.9 → 389.0 (0.0%) −0.3%

Values are medians (main → PR). Every difference is within this machine's run-to-run spread, so I don't see a macro-level regression.

Control-model micro-benchmarks (PerfBench.ControlModel, five runs per side): time per operation moved in both directions, within noise. No benchmark slowed consistently. Allocations are byte-identical except for one deterministic change. Each benchmark that mounts and unmounts elements with callbacks now allocates +48 bytes per operation:

Benchmark Bytes/op, main → PR
Mount a leaf with one callback 4,439 → 4,487
Mount a leaf with three callbacks 10,897 → 10,945
Event-handler state allocation 4,215 → 4,263
Mount a leaf with no callbacks 1,141 → 1,141 (unchanged)

The callback-free case is unchanged, and only elements with a tag reach the handler's unmount path. That points to the new foreach (var prop in _descriptor.Properties) in DescriptorHandler.Unmount. Properties is typed as IReadOnlyList<…>, so the loop likely boxes the List<T> enumerator on every unmount. Iterating by index, or caching a per-descriptor "has property teardown" flag computed once, would remove the allocation. The Properties.Any(...) check in Mount could use the same flag. It's small, but it's paid on every unmount of every descriptor-backed control.

Why CI is failing

There are three independent causes. A fourth job fails only as a result of one of them.

1. Selftests (1/2), Packaged Selftests (MSIX) (1/2), Coverage (selftest-1): PropertyGrid_Target_Switching, Switching_ShapeShows

This is a real regression from the PR, and I reproduced it locally. main is green on the same fixture.

ItemsHostReconciliation.Unbind now calls GetCollection(control).Clear() when the host unmounts. The previous unmount path only tore down UIElement children and never cleared the sink. For a ComboBox with a selection, clearing Items raises SelectionChanged with SelectedIndex == -1. The control is still attached to its element at that point, so the element's OnSelectedIndexChanged runs during teardown.

The PropertyGrid enum editor (TypeRegistry.ResolveEnum) handles that callback with Enum.Parse(enumType, names[index]). Index −1 would throw there, so the most likely result is that the switch from the Material target (which has an enum ComboBox) to the Shape target never renders its rows, and "Position" is not found.

Verification: I confirmed that the Clear() call causes the failure. Removing that line made all five checks pass locally; with the line restored, Switching_ShapeShows fails. I didn't trace the exception itself, so the names[-1] step above is inferred from the code.

This affects users too. Any ComboBox, or other selection control backed by ItemsHost, that unmounts with a selection will now invoke its change callback with −1. Possible fixes:

  • Don't clear native sinks on terminal unmount, which matches the previous behavior.
  • Detach the element from the control's state before clearing, so the change handler has nothing to dispatch to.
  • Clear the sink under the existing echo suppression (WriteSuppressed).

A regression test that unmounts a ComboBox with a selection and asserts the callback is not invoked would cover it.

2. AOT Selftests (2/2): new ItemsHost_Observable_Keyed_Lifecycle fixture

not ok 779 ItemsHost_Observable_Keyed_Lifecycle_CRASH - InvalidOperationException: SettingsExpander was not mounted.

The fixture passes under JIT, both locally (all 15 checks) and in the regular Selftests lane. Under NativeAOT the SettingsExpander never appears in the tree. No render error precedes it in the log. This is the first selftest fixture that uses a Windows Community Toolkit control, and the new docs say the observable list works "including indirect Native AOT collection boundaries", which is exactly the scenario failing here. I haven't confirmed the root cause.

Before deciding how to handle this, it's worth confirming whether SettingsExpander can render at all under AOT. If it can, the PR's claim needs a fix. If it's a toolkit/AOT limitation unrelated to this PR, add the fixture to the runner's NativeAOT skip list (GetAotSkipPatterns in SelfTestRunner.cs) and soften the AOT wording in the docs.

3. Docs build: InlineSnippetLedgerTests.Inline_Csharp_Examples_Are_Snippet_Backed_Or_Ledgered

The new inline example in docs/_pipeline/templates/control-reconciler-protocol.md.dt (the Children = new ItemsHost<MyElement, MyControl>(...) { SetCollection = ... } block) isn't compiled by anything. The gate requires every inline C# block to be snippet-backed or explicitly allowed. Either move it into a doc app and reference it with ```csharp snippet="<topic>/<id>", or add it to AllowedInlineExamples in tests/Reactor.DocPipeline.Tests/InlineSnippetLedgerTests.cs. Moving it to a snippet is preferable, since it also gets compiled.

4. Merged coverage

This fails only because the selftest coverage lane above produced no numbers. It should clear once the PropertyGrid regression is fixed.

@kusutori

Copy link
Copy Markdown
Contributor Author

Thanks Alexandre Zollinger Chohfi (@azchohfi) for the detailed review and benchmark probes. I went through each review point and pushed the fixes in c8a3c92 and the follow-up performance optimization in 92ea6ce7.

Fixes

  • Unmount item and generated slot children while still attached, then remove them or clear their slot.
  • Remove the redundant CanUpdate pre-check and pass the original child/control to ReconcileV1Child, preserving its type-replacement and Hot Reload migration paths.
  • Restore unchanged raw-list early returns, try positional matches first, and write new raw instances on changed updates without changing the unchanged-list ItemEquals behavior.
  • Reuse matched Element entries without rebuilding maps or touching the native collection unnecessarily. Keep dirty/context/Hot Reload checks and refresh callback/gesture closures when skipping unchanged children.
  • Cache the descriptor's property-teardown flag and use indexed iteration, removing the extra enumerator allocation. Suppress selection events during terminal collection clearing, fixing the PropertyGrid regression.
  • Add the requested lifecycle/context/native-items/rooting cases, re-query live descendants for identity checks, and cover the four incompatible Items setter shapes in generator tests.
  • Update the XML/guide contracts and move the inline example into a compiled doc snippet.
  • Fix the AOT fixture by explicitly registering the Toolkit XAML metadata provider rather than adding an AOT skip. Downstream testing also exposed duplicated ComboBox entries after managed-wrapper recreation; ItemsHost state now lives on the native control through an attached property.

Core performance

Using your six probes, original main merge-base aed6870 vs updated code, interleaved on the same Ryzen 5 7500F / 32 GB machine. Windows x64 Release, .NET 10.0.11; one discarded warm-up pair, eight measured pairs, 500 updates per repetition. These are JIT measurements, not AOT timings.

Scenario main µs/op updated µs/op B/op, main → updated
100 Elements, same array 4.37 28.20 1384.32 → 1389.30
100 Elements, equal fresh instances 18.22 28.69 264.51 → 269.49
100 Elements, one changed 4930.20 30.01 42088.46 → 309.57
100 strings, equal fresh array 3.15 2.75 190.03 → 194.99
1000 strings, equal fresh array 20.65 14.03 247.63 → 281.39
100 strings, one changed 347.39 14.27 6663.63 → 259.73

Values are medians of per-process means. The paired timing intervals confirm the unchanged-string improvements and the remaining unchanged-Element regressions versus main. A separate same-runner A/B against the corrected pre-optimization commit (c8a3c92) showed unchanged Elements improving 17–21% and unchanged strings 33–55%; changed-string timing was within noise. These are separate runs, so their absolute times should not be compared directly.

Validation and remaining limitations

  • The full standard matrix (five macro workloads, five measured pairs; 23 micro cases, eight pairs) completed without a detected macro regression.
  • 50 wrapper-generator tests and 15 snippet-ledger tests passed for the review fixes; the final focused JIT run passed all 37 ItemsHost checks. The earlier focused Native AOT run also passed all 37 after the metadata fix. The final optimized downstream Tonarink AOT build has been manually checked.
  • Unchanged Element lists still cost more than main's blanket skip. The 1000-string allocation result also remains higher: these measurements include initial O(n) bookkeeping amortized over 500 operations, so I am not claiming all allocation differences are gone.
  • Structural reordering still uses the existing greedy placement, not the optional LIS approach. Windows x64 was checked locally; the packaged host builds, but the full packaged runtime suite and ARM64 were not run locally.

There is still room to optimize, especially the unchanged-Element path, while keeping the lifecycle/state fixes intact. Please point out anything I missed or any concerns with these changes; I am happy to revise them further.

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.

Thanks kusutoriクスとり (@kusutori), this is a big improvement. CI is green on 92ea6ce, and the PropertyGrid, AOT and docs-snippet failures from last round are all fixed. You addressed all of the earlier inline comments; we'll resolve those threads.

Perf, rerun on our side

We reran the same six ItemsHost probes against three versions: main (aed6870), the PR before your fixes (e74a652), and the PR now (92ea6ce). Each side ran 8 interleaved rounds of 2,000 updates on one machine; values are medians per update.

Scenario main PR before PR now B/op (main → now)
100 Elements, same array 5.2 µs 96.9 µs 35.0 µs 1,369 → 1,381
100 Elements, new but equal 21.8 µs 63.6 µs 35.1 µs 198 → 200
100 Elements, one changed 16,690 µs 40.1 µs 43.0 µs 42,471 → 239
100 strings, new but equal 2.4 µs 25.6 µs 4.9 µs 180 → 181
1,000 strings, new but equal 16.4 µs 378.6 µs 14.6 µs 194 → 202
100 strings, one changed 340.5 µs 61.2 µs 18.3 µs 6,656 → 246

This matches your numbers: the extra allocations are gone, and the string paths are back to roughly main's level. The remaining gap is unchanged Element lists; see the inline note.

Before merge

  • Bench count: please keep the ItemsHost benchmarks and update the perf harness's expected count from 17 to 23 (details inline on AllBenches.cs). We're tracking the CI gap that let this through in #1373.
  • Guide: the teardown-order sentence in the guide is reversed.
  • Tests: add a test for the callback/gesture refresh on skipped items.
  • Callbacks during unmount: generated plain-event trampolines still get a callback during terminal unmount.

Not tied to a changed line

  • CHANGELOG.md has no entry. Please add SetCollection and the three-callback ImperativeBridged overload under Added, and incremental ItemsHost reconciliation plus generated slot teardown under Fixed.
  • plugins/reactor/skills/reactor-wrapping/SKILL.md (the Items inference row, around line 77) still only says "a params object[] items slot". One sentence would help agents: generated Items reconcile incrementally, .WithKey(...) preserves item state across reorders, and writable IList<object> sinks get an observable list on mount.

The rest are inline. Happy to help with any of these.

Comment thread tests/perf_bench/PerfBench.ControlModel/Benches/AllBenches.cs Outdated
Comment thread tests/perf_bench/PerfBench.ControlModel/Benches/ItemsHostBenches.cs Outdated
Comment thread docs/_pipeline/templates/control-reconciler-protocol.md.dt Outdated
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs
Comment thread src/Reactor/Core/V1Protocol/ItemsHostReconciliation.cs
Comment thread src/Reactor/Core/V1Protocol/ChildrenStrategy.cs Outdated
Comment thread docs/_pipeline/templates/control-reconciler-protocol.md.dt Outdated
Comment thread src/Reactor/Core/V1Protocol/Descriptor/ControlDescriptor.cs
@kusutori

kusutoriクスとり (kusutori) commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks Alexandre Zollinger Chohfi (@azchohfi) for the detailed review! I've addressed the follow-up feedback in 4f5cb8e and added the suggested same-source fast path in 6320d4a.

Changes:

  • Strengthen behavioral coverage for live descendant identity, updated callbacks/gestures, native reordering and selection preservation; verify relevant checks fail with the implementation deliberately broken
  • Fix terminal selection callbacks during generated-control teardown and header cleanup through the actual built-in Expander factory path
  • Keep ItemsHost bookkeeping in the existing native-attached ReactorState; correct the lifecycle docs, AOT/toolkit-metadata explanation, and benchmark names/count
  • Skip the item walk for retained immutable sources when dirty-state, context, theme and forced-render guards allow it, with dedicated behavioral/mutation checks

Performance

This is before/after the latest optimization (4f5cb8ee → 6320d4a0), not a new comparison against upstream main. Same Windows x64 machine, Release build, repository benchmark runner: all five macro scenarios and 23 micro cases. Micro results use eight measured paired rounds after warmup, 2,000 iterations each.

Scenario Before µs/op After µs/op Paired time Δ [95% CI] Before → after B/op
IH1: 100 Elements, same array 17.92 2.57 -85.6% [-86.09, -85.19] 1319.2 → 1319.2
IH2: 100 Elements, equal fresh 18.39 18.63 +1.6% [-0.04, +3.28] 199.3 → 199.3
IH3: 100 Elements, one changed 20.51 20.89 +1.7% [+0.23, +3.17] 239.3 → 239.3
IH4: 100 strings, equal arrays 1.97 2.10 +7.1% [-0.49, +14.66] 180.7 → 180.7
IH5: 1,000 strings, equal arrays 8.50 8.52 -0.8% [-2.75, +1.14] 202.3 → 202.3
IH6: 100 strings, one changed 13.32 14.18 +4.6% [+0.79, +8.32] 245.4 → 245.4

Displayed values are medians across matched rounds; Δ is the mean paired percentage change. IH1 improves substantially without changing allocations. All five macro timing/allocation intervals include zero. IH3 and IH6 are slightly slower in this run, It might just be measurement error

Validation: full Reactor.Tests under en-US (14,520 passed), full Advanced/SourceMap suites, 65 targeted ItemsHost checks, full Native AOT and packaged selftests, real-input Pan E2E, and benchmark-helper assertions passed. My App builds successfully with the revised sources under Native AOT

Also, a correction to my earlier ComboBox explanation: I ran into the issue shown in the image below.:
88a2b69a8fb799b98f92aa228fed1599

but haven't reproduced them again or confirmed the proposed cause. The state was already native-attached. If I establish a reproducible problem, I'll open a separate issue.

There is still room to improve performance, particularly for fresh sources. Please feel free to point out anything else that needs adjusting.

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.

Thanks kusutoriクスとり (@kusutori), this round addressed essentially everything from the last review, with tests that fail when the code is broken. The same-array gate in particular closes the biggest perf gap. I'm resolving the previous threads.

Perf: main vs last round vs now

We reran the six ItemsHost probes against main (aed6870), 92ea6ce and 6320d4a. Each side ran 8 interleaved rounds of 2,000 updates on one machine; values are medians per update.

Scenario main 92ea6ce 6320d4a B/op (main → now)
100 Elements, same array 5.0 µs 23.9 µs 5.2 µs 1,369 → 1,380
100 Elements, new but equal 16.5 µs 26.8 µs 28.4 µs 198 → 199
100 Elements, one changed 15,468 µs 48.6 µs 50.9 µs 42,471 → 239
100 strings, new but equal 3.1 µs 4.7 µs 4.7 µs 180 → 181
1,000 strings, new but equal 20.8 µs 14.6 µs 14.5 µs 194 → 202
100 strings, one changed 369 µs 16.2 µs 16.0 µs 3,376 → 213

This agrees with your before/after numbers. The same-array case is now on par with main. The "new but equal" Element case (about +12 µs for 100 items) is what remains, as you noted, and that's fine to leave for a follow-up.

Before merge

  • The new ../aot-support.md link should fail the strict MkDocs build (inline).
  • The PR now conflicts with main in CHANGELOG.md (that's the only conflicting file). Please rebase or merge main and keep both sets of [Unreleased] entries.

The remaining comments are smaller: one context-propagation gap for nested item hosts (fine as a follow-up issue), doc accuracy and placement, and two tests that need a positive control or an extra path. CI hasn't run on these commits yet; we'll approve the workflows.

Comment on lines +270 to +271
code-only Native AOT apps may also need explicit provider registration (see
[AOT support](../aot-support.md)).

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 relative link points outside the MkDocs docs_dir (mkdocs.yml sets docs_dir: docs/guide). A .md link that doesn't resolve triggers links.not_found: warn, and both ci.yml and docs.yml run mkdocs build --strict, so this should fail the docs build. (The existing ../ links in win2d-canvas.md point at directories, which MkDocs ignores, so they're not a precedent.) Other guide pages link outside the guide with an absolute URL, for example:

[AOT support](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/aot-support.md)

Then recompile this topic.

Comment on lines +245 to +256
private static bool HasChangedItemContext(ItemsHostState state, Reconciler reconciler)
{
if (!reconciler.HasActiveContextValues) return false;
// ItemsControl and third-party hosts need not expose their items as
// ordinary content children. Query the tracked roots, not the host's
// visual subtree, so unexpanded/unrealized items are covered as well.
foreach (var item in state.Items)
if (item is { Source: Element, Value: UIElement child }
&& reconciler.ShouldDeclineSkip(child))
return true;
return false;
}

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.

Nice gate overall. Each of the dirty, forced-render and theme guards has a fixture that fails without it.

One gap: ShouldDeclineSkip(child) walks the item's subtree through HasConsumedContextChangedInSubtree → ForEachReactorChildControl. That walk only visits Panel, Border, ScrollViewer, ContentControl.Content and similar. It never reads ReactorState.ItemsHostState.Items, so it doesn't look inside a nested ItemsHost (an item that is itself a generated ListBox or SettingsExpander) or an Expander.Header. With an unchanged outer array, a UseContext consumer inside one of those keeps the stale value. main skipped same-reference sources unconditionally, so this is an incomplete guard rather than a regression.

Since item state now lives on ReactorState, HasConsumedContextChangedInSubtree could also visit TryGetReactorState(control)?.ItemsHostState?.Items (values that are UIElement with an Element source). A fixture with an outer same-array host, an inner generated ListBox item and a context-consuming row would lock it in. Fine as a follow-up issue if you prefer.

Comment thread docs/aot-support.md
Comment on lines +25 to +41
### Code-only apps wrapping toolkit controls

An observable `ItemsHost` list has generated WinRT exposure, but that only covers
the collection ABI, not the control library's XAML templates. A code-only AOT app
may need to statically root and register the toolkit's generated metadata provider
before mounting wrapped controls. For example, with WCT SettingsControls:

```csharp
ReactorApp.RegisterControlAssembly(
new CommunityToolkit.WinUI.Controls.SettingsControlsRns.
CommunityToolkit_WinUI_Controls_SettingsControls_XamlTypeInfo.XamlMetaDataProvider());
```

The concrete provider name depends on the library. The SettingsExpander selftest
uses this registration explicitly; passing it is not proof of automatic metadata
discovery under trimming.

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 ### subsection landed between the ## What does *not* work under AOT (today) heading and that section's existing intro ("These subsystems compile cleanly…") and table. The PropertyGrid, DataGrid, UseObservable and devtools rows now read as part of the toolkit subsection. Could you move it below the table, or give the existing intro and table their own ### heading?

Comment on lines +437 to +438
/// call engine-internal helpers for bindings without owned child lifetime,
/// such as rebuilding a RichTextBlock's blocks.

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.

RichTextBlock isn't a good example of "without owned child lifetime". RebuildRichTextBlocks / UpdateRichTextBlocks mount and keep inline UI Element children (#480), and their teardown relies on the engine's UnmountInlineUIChildren special case. Could you drop the example, or use a binding that really has no Element children? The same example appears in the guide's overload table (control-reconciler-protocol.md.dt:401).

.PARAMETER MicroIterations
Inner iterations per micro repetition (default 1000) over which each bench's
mean ns/op and allocated bytes/op are averaged. The suite runs 17 benches
mean ns/op and allocated bytes/op are averaged. The suite runs 23 benches

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.

Thanks for updating the count. The description next to it still says "the spec-047 M1–M14 set plus 3 supplementary", which adds up to 17. Same in PerfLib.ps1:87-90, :755 and the PR-comment text at :776 ("spec-047 M1–M13 + 3 supplementary"). Could you add "+ IH1–IH6 ItemsHost benches" in those four places so the /perf output and docs match the 23?

Comment on lines +49 to +50
The generator normally emits `ReactorApp.RegisterControlAssembly` in the element's
static constructor, so a control library's

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.

Small accuracy point: the generator emits ReactorApp.TryRegisterControlAssembly(typeof(X).Assembly) (WrapperGenerator.cs ~1759), not RegisterControlAssembly. The Try form quietly returns false when the provider has been trimmed, which is exactly the case the next paragraph describes. Naming the Try call and the symptom (a 0xC000027B crash when the control is first realized) would tell an agent when explicit registration is needed.

Comment thread CHANGELOG.md Outdated

### Fixed

- Flat `ItemsHost` children reconcile incrementally, unchanged source snapshots can skip the item walk when state/context/theme/forced-render guards allow it, and generated Element slots receive terminal teardown while still parented (specs 047/058).

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.

Two small things on the entries here and at line 31:

  • main already skipped same-reference sources unconditionally, so "unchanged source snapshots can skip the item walk" reads like a new optimisation. The user-visible fix is that a same-instance items array no longer swallows context, theme or forced-render updates to Element items. Worth rewording along those lines.
  • Both entries cite specs 047/058, but neither spec mentions SetCollection, the three-callback ImperativeBridged, terminal slot teardown or incremental ItemsHost. Spec 058 still describes the old generated shape. Either add a short follow-up note to those specs, or link the guide section instead.

Comment on lines +85 to +90
list.SelectedIndex = 0;
H.Check("ItemsHost_PlainEvent_SelectionWorks", calls == 1);
calls = 0;
reconciler.UnmountChild(list);
await Harness.Render();
H.Check("ItemsHost_PlainEvent_NoTerminalCallback", calls == 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.

This test would also pass if the terminal Clear() never raised SelectionChanged, because nothing shows that it does. The only thing stopping the callback is state.Element = null in Unbind, and that only matters if the clear raises the event. Could you add a positive control? For example, on a second mounted instance whose tag is still live: select an item, call list.Items.Clear() directly and assert calls == 1. Also assert list.Items.Count == 0 after unmount, as the ComboBox sibling test does.

Comment on lines +4771 to +4776
unmount: static (ctx, c) =>
{
if (Reconciler.GetElementTag(c) is not ExpanderElement { HeaderTemplate: not null }) return;
if (c.Header is global::Microsoft.UI.Xaml.UIElement child)
ctx.Reconciler.UnmountChild(child);
c.Header = null;

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.

No test runs this descriptor-side unmount. The built-in factory registers ExpanderHandler (Dsl.cs RegDecorator<ExpanderElement, ExpanderHandler>), so ItemsHost_Expander_HeaderCleanup only exercises ExpanderHandler.Unmount. Deleting this block would keep everything green. A variant of that fixture that registers ExpanderElement.Descriptor in an isolated registry, like the other descriptor-isolated selftests, would cover it. This matters because the ImperativeBridged XML doc now points to this as the reference pattern.

@kusutori

Copy link
Copy Markdown
Contributor Author

Thanks for the rerun and the detailed follow-up! Merged main at 20a076b in 882d6db, retaining both sets of Unreleased entries, and addressed the merge-preparation feedback in 0a37b9a:

  • Fix the AOT link for strict MkDocs and regenerate the guide; move the toolkit note below the existing limitations table
  • Remove the inaccurate RichTextBlock example, correct the TryRegisterControlAssembly guidance, account for IH1–IH6 in the benchmark descriptions, and link the changelog entries to the maintained guide
  • Add a live native-Clear positive control and post-unmount item-count assertion
  • Exercise ExpanderElement.Descriptor through a per-reconciler registration, separately from the public factory's handler path

Validation: Release host build with zero warnings/errors; all 70 targeted ItemsHost checks, 15 snippet-ledger tests, strict MkDocs build, and the 380 + 104 benchmark-helper assertions passed. Omitting tag clearing and descriptor header teardown caused the corresponding tests to fail; both mutations were restored and the targeted build/tests passed again.

I have left the nested ItemsHost/header context-propagation gap for a separate follow-up, as suggested. No further performance changes were made in this round, and I did not rerun the full performance matrix or Native AOT suite.

This branch has not been deployed

No deployments
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.

[Bug] Generated SettingsExpander ItemsHost does not reconcile dynamic children correctly

2 participants