Skip to content

Commit 9d9fa67

Browse files
spec(047): Phase 3-final Batch G1 — flat ItemsHost ports (ListBox, ComboBox, RadioButtons)
Migrate the three flat-Items descriptors off the .OneWay<string[]> escape-hatch and onto the ItemsHost child strategy delivered by G-prep. The engine now dispatches ItemsHost inline between RentControl and the prop loop, so SelectedIndex's initial write lands against a populated collection (no more silent clamp-to-minus-one against empty Items). Shipped: - ListBoxDescriptor: ItemsHost flat — string[] -> IReadOnlyList<object> via array reference covariance; ListBoxItemsEqual helper removed. - ComboBoxDescriptor: ItemsHost flat — string items OR Element items (ItemElements wins when non-null); the setter escape-hatch the prior port required is no longer needed for the Items population path. - RadioButtonsDescriptor: ItemsHost flat — same string[] projection; RadioButtonsItemsEqual helper removed. - Three new TAP fixtures (Desc_<Name>_Items_*) exercise the dispatch ordering: initial SelectedIndex honored against populated items, no mount-time echo, clear-to-empty + repopulate cycle, same-ref idempotent short-circuit. Carved (none): all three controls fit the flat-IList<object> shape; typed templated lists go through G2. V1 ON: 534 ok / 0 failures V1 OFF: 534 ok / 0 failures
1 parent ad8c2de commit 9d9fa67

5 files changed

Lines changed: 293 additions & 90 deletions

File tree

src/Reactor/Core/V1Protocol/Descriptor/Descriptors/ComboBoxDescriptor.cs

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,16 @@ namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
2525
/// conditional per the legacy guards.</item>
2626
/// </list></para>
2727
///
28-
/// <para><b>Known gaps vs. hand-coded handler — Items collection
29-
/// escape-hatched:</b> the descriptor does NOT touch <c>cb.Items</c>.
30-
/// ComboBox's items collection requires the legacy arm's full
31-
/// mode-switch logic (string[] vs Element[] keyed reconciliation
32-
/// against <c>requestRerender</c>) — none of which the descriptor
33-
/// builders can yet express. Authors who need ComboBox items must
34-
/// either:
35-
/// <list type="bullet">
36-
/// <item>Run under V1 OFF (legacy arm handles Items + SelectedIndex
37-
/// together).</item>
38-
/// <item>Populate <c>cb.Items</c> via a <c>.Set</c> setter chain (the
39-
/// setter runs after the descriptor's prop writes and can append items
40-
/// directly — this trades reconciliation for an imperative escape).</item>
41-
/// </list>
42-
/// The fixture exercises SelectedIndex / Header / DropDownOpened/Closed
43-
/// only — items are pre-populated via the setter chain to prove the
44-
/// descriptor's SelectedIndex write coordinates with a populated list.</para>
28+
/// <para><b>Items handling (Phase 3-final batch G1):</b> declared via the
29+
/// <see cref="ItemsHost{TElement,TControl}"/> child strategy. The engine
30+
/// populates <c>cb.Items</c> BEFORE the prop loop so <c>SelectedIndex</c>'s
31+
/// initial write lands against a populated collection. Both string items
32+
/// (<see cref="ComboBoxElement.Items"/>) and <see cref="Element"/> items
33+
/// (<see cref="ComboBoxElement.ItemElements"/>) are supported — when
34+
/// <c>ItemElements</c> is non-null it takes precedence and the engine routes
35+
/// each child through the reconciler so descendant component state survives
36+
/// re-renders. Reconciliation is positional (clear + add on structural
37+
/// delta); keyed reconciliation for templated lists lands in batch G2.</para>
4538
/// </summary>
4639
[Experimental("REACTOR_V1_PREVIEW")]
4740
internal static class ComboBoxDescriptor
@@ -63,7 +56,18 @@ internal static class ComboBoxDescriptor
6356
public static readonly ControlDescriptor<ComboBoxElement, WinUI.ComboBox> Descriptor =
6457
new ControlDescriptor<ComboBoxElement, WinUI.ComboBox>
6558
{
66-
Children = new None<ComboBoxElement, WinUI.ComboBox>(),
59+
// §14 Phase 3-final batch G1: items declared as a child strategy.
60+
// Engine dispatches BEFORE the prop loop so the initial
61+
// SelectedIndex write lands against a populated collection.
62+
// ItemElements takes precedence when non-null (typed Element
63+
// items → engine routes through Reconciler.MountChild); otherwise
64+
// the string[] items are used. Both casts are valid via array
65+
// reference covariance (reference element types).
66+
Children = new ItemsHost<ComboBoxElement, WinUI.ComboBox>(
67+
GetItems: static e => e.ItemElements is not null
68+
? (global::System.Collections.Generic.IReadOnlyList<object>)e.ItemElements
69+
: (global::System.Collections.Generic.IReadOnlyList<object>)e.Items,
70+
GetCollection: static c => c.Items),
6771
GetSetters = static e => e.Setters,
6872
}
6973
.HandCodedControlled<ComboBoxEventPayload, int, WinUI.SelectionChangedEventHandler>(

src/Reactor/Core/V1Protocol/Descriptor/Descriptors/ListBoxDescriptor.cs

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
66

77
/// <summary>
8-
/// Spec 047 §14 Phase 3 (batch 11) — descriptor variant of the hand-coded
9-
/// <c>MountListBox</c> / <c>UpdateListBox</c> arms in
10-
/// <see cref="Reconciler"/>.
8+
/// Spec 047 §14 Phase 3 (batch 11; revised in Phase 3-final batch G1) —
9+
/// descriptor variant of the hand-coded <c>MountListBox</c> /
10+
/// <c>UpdateListBox</c> arms in <see cref="Reconciler"/>.
1111
///
1212
/// <para><b>Coverage:</b>
1313
/// <list type="bullet">
14-
/// <item><c>Items</c> — one-way Clear + Add cycle gated on a sequence
15-
/// compare (mirrors the legacy <c>StringArrayEquals</c> guard). Same
16-
/// non-keyed shape as <see cref="RadioButtonsDescriptor"/>.</item>
14+
/// <item><c>Items</c> — declared via the
15+
/// <see cref="ItemsHost{TElement,TControl}"/> child strategy. The engine
16+
/// populates the items collection BEFORE the prop loop runs (see
17+
/// <see cref="DescriptorHandler{TElement,TControl}"/>), so
18+
/// <c>SelectedIndex</c>'s initial write lands against a populated list.</item>
1719
/// <item><c>SelectedIndex</c> — <see cref="ControlDescriptor{TElement,TControl}.HandCodedControlled{TPayload,TValue,TDelegate}"/>
1820
/// with a typed <c>SelectionChanged</c> trampoline. The trampoline fires
1921
/// BOTH <c>OnSelectedIndexChanged</c> and the multi-select snapshot
@@ -22,7 +24,7 @@ namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
2224
/// </list></para>
2325
///
2426
/// <para><b>Known gaps vs. hand-coded handler:</b> Items reconciliation is
25-
/// non-keyed (full rebuild on any sequence delta). Acceptable for short
27+
/// non-keyed (full rebuild on any structural delta). Acceptable for short
2628
/// ListBoxes (typical 3–15 options).</para>
2729
/// </summary>
2830
[Experimental("REACTOR_V1_PREVIEW")]
@@ -51,19 +53,20 @@ internal static class ListBoxDescriptor
5153
public static readonly ControlDescriptor<ListBoxElement, WinUI.ListBox> Descriptor =
5254
new ControlDescriptor<ListBoxElement, WinUI.ListBox>
5355
{
54-
Children = new None<ListBoxElement, WinUI.ListBox>(),
56+
// §14 Phase 3-final batch G1: items declared as a child strategy.
57+
// The engine dispatches ItemsHost BEFORE the prop loop, so
58+
// SelectedIndex's initial write lands against the populated
59+
// collection (WinUI clamps SelectedIndex against an empty
60+
// Items collection).
61+
//
62+
// string[] -> IReadOnlyList<object> is valid via array reference
63+
// covariance (reference element types only) — zero-alloc and the
64+
// cast never throws at runtime for empty or non-empty arrays.
65+
Children = new ItemsHost<ListBoxElement, WinUI.ListBox>(
66+
GetItems: static e => (global::System.Collections.Generic.IReadOnlyList<object>)e.Items,
67+
GetCollection: static c => c.Items),
5568
GetSetters = static e => e.Setters,
5669
}
57-
// Items BEFORE SelectedIndex so SelectedIndex lands against the
58-
// populated collection.
59-
.OneWay<string[]>(
60-
get: static e => e.Items,
61-
set: static (c, items) =>
62-
{
63-
if (ListBoxItemsEqual(c.Items, items)) return;
64-
c.Items.Clear();
65-
foreach (var item in items) c.Items.Add(item);
66-
})
6770
.HandCodedControlled<ListBoxEventPayload, int, WinUI.SelectionChangedEventHandler>(
6871
get: static e => e.SelectedIndex,
6972
set: static (c, v) => c.SelectedIndex = v,
@@ -81,17 +84,4 @@ e.OnSelectedIndexChanged is not null
8184
trampoline: SelectionChangedTrampoline,
8285
slotIsNull: static p => p.SelectionChangedTrampoline is null,
8386
setSlot: static (p, h) => p.SelectionChangedTrampoline = h);
84-
85-
private static bool ListBoxItemsEqual(
86-
global::Microsoft.UI.Xaml.Controls.ItemCollection existing, string[] incoming)
87-
{
88-
if (existing.Count != incoming.Length) return false;
89-
for (int i = 0; i < incoming.Length; i++)
90-
{
91-
if (!ReferenceEquals(existing[i], incoming[i])
92-
&& existing[i] as string != incoming[i])
93-
return false;
94-
}
95-
return true;
96-
}
9787
}

src/Reactor/Core/V1Protocol/Descriptor/Descriptors/RadioButtonsDescriptor.cs

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
1818
/// gates on <c>ChangeEchoSuppressor.ShouldSuppress(c)</c> so programmatic
1919
/// index writes don't echo through <c>OnSelectedIndexChanged</c>.</item>
2020
/// <item><c>Header</c> — one-way conditional per the legacy guard.</item>
21-
/// <item><c>Items</c> — escape-hatched via a transparent <c>OneWay</c> entry
22-
/// that runs a Clear + Add cycle when the new array differs by reference
23-
/// OR by content. This matches the legacy <c>StringArrayEquals</c> guard
24-
/// but does NOT do keyed reconciliation — every item-change replaces the
25-
/// full list (acceptable for a RadioButtons control whose typical use is
26-
/// 3–7 fixed options).</item>
21+
/// <item><c>Items</c> — declared via the
22+
/// <see cref="ItemsHost{TElement,TControl}"/> child strategy (Phase 3-final
23+
/// batch G1). The engine populates the items collection BEFORE the prop
24+
/// loop runs, so <c>SelectedIndex</c>'s initial write lands against a
25+
/// populated list (WinUI clamps SelectedIndex against an empty Items
26+
/// collection). Reconciliation is positional (clear + add on structural
27+
/// delta) — acceptable for a RadioButtons control whose typical use is
28+
/// 3–7 fixed options.</item>
2729
/// </list></para>
2830
///
2931
/// <para><b>Known gaps vs. hand-coded handler:</b>
@@ -48,30 +50,17 @@ internal static class RadioButtonsDescriptor
4850
public static readonly ControlDescriptor<RadioButtonsElement, WinUI.RadioButtons> Descriptor =
4951
new ControlDescriptor<RadioButtonsElement, WinUI.RadioButtons>
5052
{
51-
Children = new None<RadioButtonsElement, WinUI.RadioButtons>(),
53+
// §14 Phase 3-final batch G1: items declared as a child strategy.
54+
// The engine dispatches ItemsHost BEFORE the prop loop so the
55+
// initial SelectedIndex write lands against a populated list
56+
// (WinUI clamps SelectedIndex against Items.Count). string[] ->
57+
// IReadOnlyList<object> is valid via array reference covariance
58+
// (reference element types only).
59+
Children = new ItemsHost<RadioButtonsElement, WinUI.RadioButtons>(
60+
GetItems: static e => (global::System.Collections.Generic.IReadOnlyList<object>)e.Items,
61+
GetCollection: static c => c.Items),
5262
GetSetters = static e => e.Setters,
5363
}
54-
// Items BEFORE SelectedIndex so the index is honored against the
55-
// populated list (WinUI clamps SelectedIndex against Items.Count).
56-
// We pull the string array, populate eagerly each pass, and rely on
57-
// .HandCodedControlled to gate the index write.
58-
.OneWay<string[]>(
59-
get: static e => e.Items,
60-
set: static (c, items) =>
61-
{
62-
// Idempotent in steady state: if the existing items match
63-
// by sequence we skip the rebuild. This mirrors the legacy
64-
// StringArrayEquals guard without round-tripping through a
65-
// separate compare in the descriptor framework.
66-
if (RadioButtonsItemsEqual(c.Items, items)) return;
67-
// RadioButtons.Items.Clear / Add raises SelectionChanged
68-
// (template-driven SelectedIndex coercion) — both the
69-
// descriptor and the legacy arm let this echo through to
70-
// OnSelectedIndexChanged. Documented gap; the fixture
71-
// bounds the count rather than asserting zero.
72-
c.Items.Clear();
73-
foreach (var item in items) c.Items.Add(item);
74-
})
7564
.HandCodedControlled<RadioButtonsEventPayload, int, WinUI.SelectionChangedEventHandler>(
7665
get: static e => e.SelectedIndex,
7766
set: static (c, v) => c.SelectedIndex = v,
@@ -85,17 +74,4 @@ internal static class RadioButtonsDescriptor
8574
get: static e => e.Header,
8675
set: static (c, v) => c.Header = v,
8776
shouldWrite: static e => e.Header is not null);
88-
89-
private static bool RadioButtonsItemsEqual(
90-
global::System.Collections.Generic.IList<object> existing, string[] incoming)
91-
{
92-
if (existing.Count != incoming.Length) return false;
93-
for (int i = 0; i < incoming.Length; i++)
94-
{
95-
if (!ReferenceEquals(existing[i], incoming[i])
96-
&& existing[i] as string != incoming[i])
97-
return false;
98-
}
99-
return true;
100-
}
10177
}

0 commit comments

Comments
 (0)