diff --git a/CHANGELOG.md b/CHANGELOG.md index 94164ccf8..8721d9ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,25 @@ Conventions for contributors: ### Added +- **Component inspection for devtools: `ReactorDiagnostics.DescribeComponent`, + `TrySetState`, `Rerender`, `GetAppliedProperties`, `GetReferenceEdges`.** An + inspector can go from a realized control to the component behind it: its name, + kind, props, hooks and consumed contexts as a text `ComponentSnapshot`; set a + `UseState` / `UseReducer` / `UsePersisted` value from text (parsed to the hook's + type, refused with a reason otherwise) and re-render it; list the WinUI + properties a control's modifiers set (including the caption-derived default + `AutomationProperties.Name`); and list the references it declares, including + pending ones whose target has not mounted. Component snapshots and property + lists are text — no live component, element or hook object; a resolved + reference edge carries its mounted target control. Reads + are on demand and UI-thread-affine. The render path gains only reference + stores: per component, `UseContext` records the context it read and + reference-list wiring keeps the authored list; per host render, the host + records which root produced the displayed control (and, when it renders into its + `ContentTarget`, that it owns it). With source mapping on, an open + `ContentDialog` is tagged with its element so its chrome resolves to the + `ContentDialog(...)` call site (spec 010, spec 057 §3.1). + - **`REACTOR_DYM_006`: color strings that render gray** (issue #1375, spec 061 §7.3). A new default-on **Warning** flags compile-time-constant color strings that `BrushHelper` can't parse and therefore silently fall back to gray: `.Background("orange")`, `.Foreground("#888")`, diff --git a/docs/_pipeline/apps/diagnostics/App.cs b/docs/_pipeline/apps/diagnostics/App.cs index da19cc4bd..e6ba0fc77 100644 --- a/docs/_pipeline/apps/diagnostics/App.cs +++ b/docs/_pipeline/apps/diagnostics/App.cs @@ -1,7 +1,9 @@ using System.Diagnostics.Tracing; using Microsoft.UI.Reactor; using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Core.Diagnostics; using Microsoft.UI.Reactor.Diagnostics; +using Microsoft.UI.Xaml; using static Microsoft.UI.Reactor.Factories; ReactorApp.Run("Diagnostics Demo"); @@ -46,3 +48,30 @@ public NavigationOverlay() public void Dispose() => _subscription.Dispose(); } // + +public static class ComponentInspector +{ + public static void Inspect(UIElement wrapper, UIElement control) + { + // + // The Border wrapper every Component() / RenderEachTime / Memo mounts into, + // or a host's root content (window.Content, a ReactorHostControl). + if (ReactorDiagnostics.DescribeComponent(wrapper) is { } component) + { + // component.Name: "Counter", "Memo in MainPage.Render", "render in App.Main" + foreach (var hook in component.State) + Console.WriteLine($"{hook.Index} {hook.Kind} {hook.Type} = {hook.Value}"); + + // Parsed to the hook's type; same semantics as calling the UseState setter. + if (!ReactorDiagnostics.TrySetState(wrapper, 0, "42", out var error)) + Console.WriteLine(error); // "'abc' is not a valid int", "... has kind 'ref'; ..." + + ReactorDiagnostics.Rerender(wrapper); // bypasses memoization, like a state change + } + + // Which WinUI properties this control's common modifiers set (.Width, .Margin, ...). + var owned = ReactorDiagnostics.GetAppliedProperties(control); + // + _ = owned; + } +} diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 5f87ed514..ff0ed2573 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -321,6 +321,67 @@ small allocation per *component*, never per leaf, and nothing while the flag is `[ReactorSourceTransparent]`, which is what `Pending(fallback, child)` does; `intl.RichMessage` cannot, because it is an instance method. +## Inspecting components + +`ReactorDiagnostics` (namespace `Microsoft.UI.Reactor.Core.Diagnostics`) lets an +inspector go from a realized control to the component behind it and make +development-time edits. It is a provisional surface for devtools. These inspection +members must be called on the UI thread that owns the element (`GetHosts()` is the +exception: it is safe from any thread). A `ComponentSnapshot` and the property lists are +plain text: they hold no reference into the component, so they can be kept or +sent across a process boundary. The one live object is a resolved reference +edge's `Target`, the mounted control it points at. + +```csharp snippet="diagnostics/inspect-component" +``` + +Values are formatted for display: strings are quoted, collections are summarised +as `List (n items)` from the count they advertise (a lazy sequence is never +enumerated and shows `(count unknown)`), and long values are cut at 200 characters. +Values are not redacted: like other in-process inspectors, Reactor reports what +the app holds, and an inspector that shows or forwards values where a secret must +not appear (a log, a recording, an AI agent) decides what to hide. +`TrySetState` accepts numbers, single characters, `true`/`false`, enum member +names (a `[Flags]` combination only of declared bits), dates, time spans, GUIDs, +plain text and their nullables (`null` clears a +string or nullable); it refuses any other type with a reason rather than guessing. +Hook names are not recorded, so `State` rows have an empty `Name` and are +identified by call-order index and kind. Props are read by reflection: under +NativeAOT or trimming a props type's members can be trimmed away, and `Props` then +holds a single ` (members unavailable)` row instead. + +Component lookup does not depend on source mapping. When a root renders a component +directly (`Render() => Component()`), its rendered control is also `Shell`'s +wrapper; that element describes `Shell`, and the root is reached through its +`ContentTarget`, its `ReactorHostControl` or the dev-overlay wrapper instead. A host +that installs straight into `Window.Content` has no such anchor, so in that shape its +root is not reachable. `GetAppliedProperties` reads +the element back-pointer, so it needs the control to be tagged — turn source +mapping on for full coverage. It covers common modifiers only; a property set by +the control's own element record (`TextBlock("hi")` → `Text`) is not listed. The +one exception is the `AutomationProperties.Name` Reactor derives from a captioned +control (`Button("Save")`) when you set none: it is reported as +`"DefaultAutomationName"` while the live name still equals it. + +An open `ContentDialog` is hosted in its own popup, out of reach of an ancestor +walk from the dialog's buttons, so with source mapping on the realized dialog is +tagged with its `ContentDialog(...)` element too: `ReactorSourceMap.GetSource(dialog)` +names the call site. That tag is for attribution only — the element's modifiers +apply to the collapsed placeholder — so `GetAppliedProperties` and +`GetReferenceEdges` report nothing for the dialog itself, and the tag is cleared +when the dialog closes. + +A re-render diffs element against element, not against the live control, so it +does not undo a property you edited directly on the control unless the element's +value for that property changed. + +`ReactorDiagnostics.GetReferenceEdges(control)` lists the references a control +declares (`.LabeledBy(ref)`, `.XYFocusRight(ref)`, `.DescribedBy(...)`, a +descriptor's `.Reference(...)`, …): the property, the AutomationId or expected +target type you passed, and whether it has resolved. A pending edge is the case +WinUI cannot show you: until the target mounts, the property itself +(`XYFocusRight`, `AutomationProperties.LabeledBy`) reads null. + ## Tips **For now, lean on the component name.** Wrap chunks of UI in diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index 487283a46..f1ebf1c31 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -387,6 +387,84 @@ small allocation per *component*, never per leaf, and nothing while the flag is `[ReactorSourceTransparent]`, which is what `Pending(fallback, child)` does; `intl.RichMessage` cannot, because it is an instance method. +## Inspecting components + +`ReactorDiagnostics` (namespace `Microsoft.UI.Reactor.Core.Diagnostics`) lets an +inspector go from a realized control to the component behind it and make +development-time edits. It is a provisional surface for devtools. These inspection +members must be called on the UI thread that owns the element (`GetHosts()` is the +exception: it is safe from any thread). A `ComponentSnapshot` and the property lists are +plain text: they hold no reference into the component, so they can be kept or +sent across a process boundary. The one live object is a resolved reference +edge's `Target`, the mounted control it points at. + +```csharp +// The Border wrapper every Component() / RenderEachTime / Memo mounts into, +// or a host's root content (window.Content, a ReactorHostControl). +if (ReactorDiagnostics.DescribeComponent(wrapper) is { } component) +{ + // component.Name: "Counter", "Memo in MainPage.Render", "render in App.Main" + foreach (var hook in component.State) + Console.WriteLine($"{hook.Index} {hook.Kind} {hook.Type} = {hook.Value}"); + + // Parsed to the hook's type; same semantics as calling the UseState setter. + if (!ReactorDiagnostics.TrySetState(wrapper, 0, "42", out var error)) + Console.WriteLine(error); // "'abc' is not a valid int", "... has kind 'ref'; ..." + + ReactorDiagnostics.Rerender(wrapper); // bypasses memoization, like a state change +} + +// Which WinUI properties this control's common modifiers set (.Width, .Margin, ...). +var owned = ReactorDiagnostics.GetAppliedProperties(control); +``` + +Values are formatted for display: strings are quoted, collections are summarised +as `List (n items)` from the count they advertise (a lazy sequence is never +enumerated and shows `(count unknown)`), and long values are cut at 200 characters. +Values are not redacted: like other in-process inspectors, Reactor reports what +the app holds, and an inspector that shows or forwards values where a secret must +not appear (a log, a recording, an AI agent) decides what to hide. +`TrySetState` accepts numbers, single characters, `true`/`false`, enum member +names (a `[Flags]` combination only of declared bits), dates, time spans, GUIDs, +plain text and their nullables (`null` clears a +string or nullable); it refuses any other type with a reason rather than guessing. +Hook names are not recorded, so `State` rows have an empty `Name` and are +identified by call-order index and kind. Props are read by reflection: under +NativeAOT or trimming a props type's members can be trimmed away, and `Props` then +holds a single ` (members unavailable)` row instead. + +Component lookup does not depend on source mapping. When a root renders a component +directly (`Render() => Component()`), its rendered control is also `Shell`'s +wrapper; that element describes `Shell`, and the root is reached through its +`ContentTarget`, its `ReactorHostControl` or the dev-overlay wrapper instead. A host +that installs straight into `Window.Content` has no such anchor, so in that shape its +root is not reachable. `GetAppliedProperties` reads +the element back-pointer, so it needs the control to be tagged — turn source +mapping on for full coverage. It covers common modifiers only; a property set by +the control's own element record (`TextBlock("hi")` → `Text`) is not listed. The +one exception is the `AutomationProperties.Name` Reactor derives from a captioned +control (`Button("Save")`) when you set none: it is reported as +`"DefaultAutomationName"` while the live name still equals it. + +An open `ContentDialog` is hosted in its own popup, out of reach of an ancestor +walk from the dialog's buttons, so with source mapping on the realized dialog is +tagged with its `ContentDialog(...)` element too: `ReactorSourceMap.GetSource(dialog)` +names the call site. That tag is for attribution only — the element's modifiers +apply to the collapsed placeholder — so `GetAppliedProperties` and +`GetReferenceEdges` report nothing for the dialog itself, and the tag is cleared +when the dialog closes. + +A re-render diffs element against element, not against the live control, so it +does not undo a property you edited directly on the control unless the element's +value for that property changed. + +`ReactorDiagnostics.GetReferenceEdges(control)` lists the references a control +declares (`.LabeledBy(ref)`, `.XYFocusRight(ref)`, `.DescribedBy(...)`, a +descriptor's `.Reference(...)`, …): the property, the AutomationId or expected +target type you passed, and whether it has resolved. A pending edge is the case +WinUI cannot show you: until the target mounts, the property itself +(`XYFocusRight`, `AutomationProperties.LabeledBy`) reads null. + ## Tips **For now, lean on the component name.** Wrap chunks of UI in diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index 02797f525..365a1e005 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -1239,6 +1239,17 @@ Set(string key, T value) → void Trim(int targetCount) → int TryGet(string key, ref T value) → bool +### record AppliedProperty +new(string Modifier, string Property, string Value) +string Modifier { get; init; } +string Property { get; init; } +string Value { get; init; } +Deconstruct(ref string Modifier, ref string Property, ref string Value) → void +Equals(AppliedProperty other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### record ArrayTypeMetadata new() Func> CreateElement { get; init; } @@ -1731,6 +1742,20 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record ComponentSnapshot +new(string Name, string Kind, bool IsRoot, IReadOnlyList Props, IReadOnlyList State, IReadOnlyList Contexts) +IReadOnlyList Contexts { get; init; } +IReadOnlyList Props { get; init; } +IReadOnlyList State { get; init; } +bool IsRoot { get; init; } +string Kind { get; init; } +string Name { get; init; } +Deconstruct(ref string Name, ref string Kind, ref bool IsRoot, ref IReadOnlyList Props, ref IReadOnlyList State, ref IReadOnlyList Contexts) → void +Equals(ComponentSnapshot other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### static class ComponentUseMemoCellsExtensions Component.UseMemoCells(IReadOnlyList items, Func builder, object[] dependencies) → Element[] Component.UseMemoCellsByIndex(IReadOnlyList items, IReadOnlyList changedIndices, Func builder, object[] dependencies) → Element[] @@ -1952,6 +1977,21 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record DiagnosticValue +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool MigratedByHotReload) +bool Editable { get; init; } +bool MigratedByHotReload { get; init; } +int Index { get; init; } +string Kind { get; init; } +string Name { get; init; } +string Type { get; init; } +string Value { get; init; } +Deconstruct(ref int Index, ref string Name, ref string Kind, ref string Type, ref string Value, ref bool Editable, ref bool MigratedByHotReload) → void +Equals(DiagnosticValue other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### record DirectionalTransition new(Transition enter, Transition exit) Transition EnterTransition { get; init; } @@ -4556,7 +4596,12 @@ Register(IReactorDevtoolsHost host) → void ### static class ReactorDiagnostics IReadOnlyList RecentKeyedListWarnings { get; } ClearRecentKeyedListWarnings() → void +DescribeComponent(UIElement element) → ComponentSnapshot +GetAppliedProperties(UIElement control) → IReadOnlyList GetHosts() → IReadOnlyList +GetReferenceEdges(UIElement control) → IReadOnlyList +Rerender(UIElement element) → bool +TrySetState(UIElement element, int hookIndex, string text, ref string error) → bool ### static class ReactorDisplay DisplayInfo Primary { get; } @@ -4753,6 +4798,21 @@ ToString() → string new(T initial) T Current { get; set; } +### record ReferenceEdgeSnapshot +new(string Property, bool IsList, int Index, string TargetAutomationId, string ExpectedTargetTypeName, bool IsResolved, UIElement Target) +UIElement Target { get; init; } +bool IsList { get; init; } +bool IsResolved { get; init; } +int Index { get; init; } +string ExpectedTargetTypeName { get; init; } +string Property { get; init; } +string TargetAutomationId { get; init; } +Deconstruct(ref string Property, ref bool IsList, ref int Index, ref string TargetAutomationId, ref string ExpectedTargetTypeName, ref bool IsResolved, ref UIElement Target) → void +Equals(ReferenceEdgeSnapshot other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### static class ReflectionTypeMetadataProvider CreateDescriptor(PropertyInfo property, int defaultOrder) → FieldDescriptor CreateMetadata(Type type) → TypeMetadata diff --git a/skills/reactor.api.txt b/skills/reactor.api.txt index 02797f525..365a1e005 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -1239,6 +1239,17 @@ Set(string key, T value) → void Trim(int targetCount) → int TryGet(string key, ref T value) → bool +### record AppliedProperty +new(string Modifier, string Property, string Value) +string Modifier { get; init; } +string Property { get; init; } +string Value { get; init; } +Deconstruct(ref string Modifier, ref string Property, ref string Value) → void +Equals(AppliedProperty other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### record ArrayTypeMetadata new() Func> CreateElement { get; init; } @@ -1731,6 +1742,20 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record ComponentSnapshot +new(string Name, string Kind, bool IsRoot, IReadOnlyList Props, IReadOnlyList State, IReadOnlyList Contexts) +IReadOnlyList Contexts { get; init; } +IReadOnlyList Props { get; init; } +IReadOnlyList State { get; init; } +bool IsRoot { get; init; } +string Kind { get; init; } +string Name { get; init; } +Deconstruct(ref string Name, ref string Kind, ref bool IsRoot, ref IReadOnlyList Props, ref IReadOnlyList State, ref IReadOnlyList Contexts) → void +Equals(ComponentSnapshot other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### static class ComponentUseMemoCellsExtensions Component.UseMemoCells(IReadOnlyList items, Func builder, object[] dependencies) → Element[] Component.UseMemoCellsByIndex(IReadOnlyList items, IReadOnlyList changedIndices, Func builder, object[] dependencies) → Element[] @@ -1952,6 +1977,21 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record DiagnosticValue +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool MigratedByHotReload) +bool Editable { get; init; } +bool MigratedByHotReload { get; init; } +int Index { get; init; } +string Kind { get; init; } +string Name { get; init; } +string Type { get; init; } +string Value { get; init; } +Deconstruct(ref int Index, ref string Name, ref string Kind, ref string Type, ref string Value, ref bool Editable, ref bool MigratedByHotReload) → void +Equals(DiagnosticValue other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### record DirectionalTransition new(Transition enter, Transition exit) Transition EnterTransition { get; init; } @@ -4556,7 +4596,12 @@ Register(IReactorDevtoolsHost host) → void ### static class ReactorDiagnostics IReadOnlyList RecentKeyedListWarnings { get; } ClearRecentKeyedListWarnings() → void +DescribeComponent(UIElement element) → ComponentSnapshot +GetAppliedProperties(UIElement control) → IReadOnlyList GetHosts() → IReadOnlyList +GetReferenceEdges(UIElement control) → IReadOnlyList +Rerender(UIElement element) → bool +TrySetState(UIElement element, int hookIndex, string text, ref string error) → bool ### static class ReactorDisplay DisplayInfo Primary { get; } @@ -4753,6 +4798,21 @@ ToString() → string new(T initial) T Current { get; set; } +### record ReferenceEdgeSnapshot +new(string Property, bool IsList, int Index, string TargetAutomationId, string ExpectedTargetTypeName, bool IsResolved, UIElement Target) +UIElement Target { get; init; } +bool IsList { get; init; } +bool IsResolved { get; init; } +int Index { get; init; } +string ExpectedTargetTypeName { get; init; } +string Property { get; init; } +string TargetAutomationId { get; init; } +Deconstruct(ref string Property, ref bool IsList, ref int Index, ref string TargetAutomationId, ref string ExpectedTargetTypeName, ref bool IsResolved, ref UIElement Target) → void +Equals(ReferenceEdgeSnapshot other) → bool +Equals(object obj) → bool +GetHashCode() → int +ToString() → string + ### static class ReflectionTypeMetadataProvider CreateDescriptor(PropertyInfo property, int defaultOrder) → FieldDescriptor CreateMetadata(Type type) → TypeMetadata diff --git a/src/Reactor.Devtools/DevtoolsStateTool.cs b/src/Reactor.Devtools/DevtoolsStateTool.cs index 8c8052fd7..513e3fd5d 100644 --- a/src/Reactor.Devtools/DevtoolsStateTool.cs +++ b/src/Reactor.Devtools/DevtoolsStateTool.cs @@ -156,26 +156,12 @@ private static RenderContext GetContext(Component c) }; } - // Non-enumerating count probe. Checks the non-generic ICollection first - // (array, List, ArrayList, most BCL collections), then falls back to - // reflecting a generic `Count` property for ICollection / - // IReadOnlyCollection. Returns null when the source doesn't advertise - // its size — we refuse to force enumeration to find out. - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Devtools state tool uses reflection to read collection Count.")] - [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Devtools state tool uses reflection to read collection Count.")] + // Non-enumerating count probe, shared with ReactorDiagnostics so the two + // devtools surfaces report collection sizes the same way: ICollection, then a + // public int Count; null when the source doesn't advertise its size — we + // refuse to force enumeration to find out. private static int? TryReadCollectionCount(object value) - { - if (value is global::System.Collections.ICollection coll) return coll.Count; - var countProp = value.GetType().GetProperty( - "Count", - global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.Public); - if (countProp is not null && countProp.PropertyType == typeof(int)) - { - try { return (int?)countProp.GetValue(value); } - catch { return null; } - } - return null; - } + => Microsoft.UI.Reactor.Core.Diagnostics.DiagnosticText.TryCount(value); } /// Result of the state tool — the root component's hook snapshots. diff --git a/src/Reactor.Devtools/ReferenceOverlay.cs b/src/Reactor.Devtools/ReferenceOverlay.cs index 42a87585b..3834f716c 100644 --- a/src/Reactor.Devtools/ReferenceOverlay.cs +++ b/src/Reactor.Devtools/ReferenceOverlay.cs @@ -96,23 +96,10 @@ private static ReferenceEdgeInfo MakeEdge( /// Maps a reference-entry slot to a human-readable label. Modifier-level edges /// have stable, named slots (); descriptor and /// imperative-binding edges allocate ascending slots that carry no author name, - /// so they fall back to a kind-tagged generic label. + /// so they fall back to a kind-tagged generic label. Shared with + /// ReactorDiagnostics.GetReferenceEdges so the two surfaces agree. /// - internal static string LabelForSlot(int slot) => slot switch - { - ReferenceSlots.ModifierRef_LabeledBy => "LabeledBy", - ReferenceSlots.ModifierRef_DescribedBy => "DescribedBy", - ReferenceSlots.ModifierRef_FlowsTo => "FlowsTo", - ReferenceSlots.ModifierRef_FlowsFrom => "FlowsFrom", - ReferenceSlots.ModifierRef_XYFocusUp => "XYFocusUp", - ReferenceSlots.ModifierRef_XYFocusDown => "XYFocusDown", - ReferenceSlots.ModifierRef_XYFocusLeft => "XYFocusLeft", - ReferenceSlots.ModifierRef_XYFocusRight => "XYFocusRight", - ReferenceSlots.ModifierRef_ToolTipPlacementTarget => "ToolTipPlacementTarget", - >= 200_000 => $"modifier#{slot}", - >= 100_000 => $"binding#{slot - 100_000}", - _ => $"reference#{slot}", - }; + internal static string LabelForSlot(int slot) => ReferenceSlots.Label(slot); internal static List BuildDiagnostics(List edges) { diff --git a/src/Reactor/Core/Component.cs b/src/Reactor/Core/Component.cs index ea44aeb90..7e2581b0d 100644 --- a/src/Reactor/Core/Component.cs +++ b/src/Reactor/Core/Component.cs @@ -327,6 +327,12 @@ protected Hooks.Mutation UseMutation( internal interface IPropsReceiver { void SetProps(object props); + + /// Boxed current props, for diagnostics (no reflection). + object? PropsBoxed { get; } + + /// The declared props type (TProps), for diagnostics. + Type PropsType { get; } } /// @@ -350,6 +356,10 @@ public abstract class Component : Component, IPropsReceiver, IPropsCompa void IPropsReceiver.SetProps(object props) => Props = (TProps)props; + object? IPropsReceiver.PropsBoxed => Props; + + Type IPropsReceiver.PropsType => typeof(TProps); + bool IPropsComparable.CompareProps(object? oldProps, object? newProps) => ShouldUpdate((TProps?)oldProps, (TProps?)newProps); diff --git a/src/Reactor/Core/Context.cs b/src/Reactor/Core/Context.cs index d44c49249..7ece84c5b 100644 --- a/src/Reactor/Core/Context.cs +++ b/src/Reactor/Core/Context.cs @@ -8,6 +8,10 @@ namespace Microsoft.UI.Reactor.Core; public abstract class ContextBase { internal abstract object? DefaultValueBoxed { get; } + // Diagnostics (ReactorDiagnostics.Components): non-generic access to T and the + // declaration name without reflection. + internal abstract Type ValueType { get; } + internal virtual string? DiagnosticName => null; } /// @@ -26,4 +30,6 @@ public Context(T defaultValue, [CallerMemberName] string? name = null) } internal override object? DefaultValueBoxed => DefaultValue; + internal override Type ValueType => typeof(T); + internal override string? DiagnosticName => DebugName; } diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs new file mode 100644 index 000000000..1d2e4afb3 --- /dev/null +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -0,0 +1,226 @@ +using WinUI = Microsoft.UI.Xaml.Controls; + +namespace Microsoft.UI.Reactor.Core.Diagnostics; + +/// +/// Which WinUI property each common modifier is written to — the read-side mirror of +/// Reconciler.ApplyModifiers / ApplyAccessibilityModifiers / +/// ApplyModifierReferenceEdges, for . +/// +/// Owner resolution follows the same type tests the apply path uses (Padding goes to +/// Control, Border, StackPanel, … in that order; IsEnabled only to a +/// Control), so a modifier the reconciler skips for a control type is not reported +/// for it. Type tests only: nothing here touches a live control, so the table is testable +/// headless and needs no reflection. +/// +/// Deliberately absent (not dependency-property writes): the Layout / +/// Visual bucket records (their members are listed individually), event handlers, +/// gesture and drag configs, OnMountAction/OnUnmountAction, Ref, +/// Backdrop (a window property). Scale/Rotation/Translation/ +/// CenterPoint are composition-backed UIElement facades rather than DPs, but the +/// reconciler writes them, so they are mapped. +/// AppliedModifierMapTests fails when a new modifier is neither mapped nor listed +/// as excluded. +/// +/// One modifier-applied property with its live element-side value (internal; the public surface reports it as text). +internal sealed record AppliedModifier(string Modifier, string Property, object? Value); + +internal static class AppliedModifierMap +{ + internal readonly record struct Entry(string Modifier, Func Get, Func Target); + + private static bool Is(Type t) => typeof(T).IsAssignableFrom(t); + + private static Func Always(string property) => _ => property; + + private static Func ControlOnly(string property) + => t => Is(t) ? "Control." + property : null; + + private static string? PaddingOwner(Type t) => + Is(t) ? "Control.Padding" + : Is(t) ? "Border.Padding" + : Is(t) ? "StackPanel.Padding" + : Is(t) ? "Grid.Padding" + : Is(t) ? "RelativePanel.Padding" + : Is(t) ? "TextBlock.Padding" + : null; + + private static string? CornerRadiusOwner(Type t) => + Is(t) ? "Control.CornerRadius" + : Is(t) ? "Border.CornerRadius" + : Is(t) ? "Grid.CornerRadius" + : Is(t) ? "StackPanel.CornerRadius" + : Is(t) ? "RelativePanel.CornerRadius" + : null; + + private static Func ControlOrBorder(string property) + => t => Is(t) ? "Control." + property + : Is(t) ? "Border." + property + : null; + + private static Func ControlOrTextBlock(string property) + => t => Is(t) ? "Control." + property + : Is(t) ? "TextBlock." + property + : null; + + private static string? BackgroundOwner(Type t) => + Is(t) ? "Panel.Background" + : Is(t) ? "Control.Background" + : Is(t) ? "Border.Background" + : null; + + // Mirrors Reconciler.ResolveFlyoutSlot. + private static string? AttachedFlyoutOwner(Type t) => + Is(t) ? "SplitButton.Flyout" + : Is(t) ? "Button.Flyout" + : "FlyoutBase.AttachedFlyout"; + + internal static readonly Entry[] Entries = + [ + new("RequestedTheme", m => m.RequestedTheme, Always("FrameworkElement.RequestedTheme")), + new("Margin", m => m.Margin, Always("FrameworkElement.Margin")), + new("MarginInlineStart", m => m.MarginInlineStart, Always("FrameworkElement.Margin")), + new("MarginInlineEnd", m => m.MarginInlineEnd, Always("FrameworkElement.Margin")), + new("Padding", m => m.Padding, PaddingOwner), + new("PaddingInlineStart", m => m.PaddingInlineStart, PaddingOwner), + new("PaddingInlineEnd", m => m.PaddingInlineEnd, PaddingOwner), + new("Width", m => m.Width, Always("FrameworkElement.Width")), + new("Height", m => m.Height, Always("FrameworkElement.Height")), + new("MinWidth", m => m.MinWidth, Always("FrameworkElement.MinWidth")), + new("MinHeight", m => m.MinHeight, Always("FrameworkElement.MinHeight")), + new("MaxWidth", m => m.MaxWidth, Always("FrameworkElement.MaxWidth")), + new("MaxHeight", m => m.MaxHeight, Always("FrameworkElement.MaxHeight")), + new("HorizontalAlignment", m => m.HorizontalAlignment, Always("FrameworkElement.HorizontalAlignment")), + new("VerticalAlignment", m => m.VerticalAlignment, Always("FrameworkElement.VerticalAlignment")), + new("HorizontalContentAlignment", m => m.HorizontalContentAlignment, ControlOnly("HorizontalContentAlignment")), + new("VerticalContentAlignment", m => m.VerticalContentAlignment, ControlOnly("VerticalContentAlignment")), + new("Opacity", m => m.Opacity, Always("UIElement.Opacity")), + // Composition-backed UIElement facades (not DPs), written directly or animated on the visual. + new("Scale", m => m.Scale, Always("UIElement.Scale")), + new("Rotation", m => m.Rotation, Always("UIElement.Rotation")), + new("Translation", m => m.Translation, Always("UIElement.Translation")), + new("CenterPoint", m => m.CenterPoint, Always("UIElement.CenterPoint")), + new("IsVisible", m => m.IsVisible, Always("UIElement.Visibility")), + new("RichToolTip", m => m.RichToolTip, Always("ToolTipService.ToolTip")), + // A rich tooltip wins over the string form, so ToolTip is only applied without one. + new("ToolTip", m => m.RichToolTip is null ? m.ToolTip : null, Always("ToolTipService.ToolTip")), + new("ToolTipPlacement", m => m.ToolTipPlacement, Always("ToolTipService.Placement")), + new("ToolTipPlacementTargetRef", m => m.ToolTipPlacementTargetRef, Always("ToolTipService.PlacementTarget")), + new("AttachedFlyout", m => m.AttachedFlyout, AttachedFlyoutOwner), + new("ContextFlyout", m => m.ContextFlyout, Always("UIElement.ContextFlyout")), + new("IsEnabled", m => m.IsEnabled, ControlOnly("IsEnabled")), + new("CornerRadius", m => m.CornerRadius, CornerRadiusOwner), + new("BorderBrush", m => m.BorderBrush, ControlOrBorder("BorderBrush")), + new("BorderThickness", m => m.BorderThickness, ControlOrBorder("BorderThickness")), + new("BorderInlineStart", m => m.BorderInlineStart, ControlOrBorder("BorderThickness")), + new("Background", m => m.Background, BackgroundOwner), + new("Foreground", m => m.Foreground, ControlOrTextBlock("Foreground")), + new("AutomationName", m => m.AutomationName, Always("AutomationProperties.Name")), + new("AutomationId", m => m.AutomationId, Always("AutomationProperties.AutomationId")), + new("IsDragRegion", m => m.IsDragRegion, Always("TitleBar.IsDragRegion")), + new("ElementSoundMode", m => m.ElementSoundMode, ControlOnly("ElementSoundMode")), + new("HeadingLevel", m => m.HeadingLevel, Always("AutomationProperties.HeadingLevel")), + new("IsTabStop", m => m.IsTabStop, Always("UIElement.IsTabStop")), + new("IsHitTestVisible", m => m.IsHitTestVisible, Always("UIElement.IsHitTestVisible")), + new("TabIndex", m => m.TabIndex, ControlOnly("TabIndex")), + new("AccessKey", m => m.AccessKey, Always("UIElement.AccessKey")), + new("XYFocusKeyboardNavigation", m => m.XYFocusKeyboardNavigation, Always("UIElement.XYFocusKeyboardNavigation")), + new("XYFocusUpRef", m => m.XYFocusUpRef, Always("UIElement.XYFocusUp")), + new("XYFocusDownRef", m => m.XYFocusDownRef, Always("UIElement.XYFocusDown")), + new("XYFocusLeftRef", m => m.XYFocusLeftRef, Always("UIElement.XYFocusLeft")), + new("XYFocusRightRef", m => m.XYFocusRightRef, Always("UIElement.XYFocusRight")), + new("FontFamily", m => m.FontFamily, ControlOrTextBlock("FontFamily")), + new("FontSize", m => m.FontSize, ControlOrTextBlock("FontSize")), + new("FontWeight", m => m.FontWeight, ControlOrTextBlock("FontWeight")), + + new("Accessibility.HelpText", m => m.Accessibility?.HelpText, Always("AutomationProperties.HelpText")), + new("Accessibility.FullDescription", m => m.Accessibility?.FullDescription, Always("AutomationProperties.FullDescription")), + new("Accessibility.LandmarkType", m => m.Accessibility?.LandmarkType, Always("AutomationProperties.LandmarkType")), + new("Accessibility.AccessibilityView", m => m.Accessibility?.AccessibilityView, Always("AutomationProperties.AccessibilityView")), + new("Accessibility.IsRequiredForForm", m => m.Accessibility?.IsRequiredForForm, Always("AutomationProperties.IsRequiredForForm")), + new("Accessibility.LiveSetting", m => m.Accessibility?.LiveSetting, Always("AutomationProperties.LiveSetting")), + new("Accessibility.PositionInSet", m => m.Accessibility?.PositionInSet, Always("AutomationProperties.PositionInSet")), + new("Accessibility.SizeOfSet", m => m.Accessibility?.SizeOfSet, Always("AutomationProperties.SizeOfSet")), + new("Accessibility.Level", m => m.Accessibility?.Level, Always("AutomationProperties.Level")), + new("Accessibility.ItemStatus", m => m.Accessibility?.ItemStatus, Always("AutomationProperties.ItemStatus")), + new("Accessibility.LabeledBy", m => m.Accessibility?.LabeledBy, Always("AutomationProperties.LabeledBy")), + new("Accessibility.LabeledByRef", m => m.Accessibility?.LabeledByRef, Always("AutomationProperties.LabeledBy")), + new("Accessibility.DescribedByRefs", m => m.Accessibility?.DescribedByRefs, Always("AutomationProperties.DescribedBy")), + new("Accessibility.FlowsToRefs", m => m.Accessibility?.FlowsToRefs, Always("AutomationProperties.FlowsTo")), + new("Accessibility.FlowsFromRefs", m => m.Accessibility?.FlowsFromRefs, Always("AutomationProperties.FlowsFrom")), + new("Accessibility.TabFocusNavigation", m => m.Accessibility?.TabFocusNavigation, Always("UIElement.TabFocusNavigation")), + ]; + + internal static IReadOnlyList Describe(ElementModifiers modifiers, Type controlType) + { + var result = Entries + .Select(entry => new AppliedModifier(entry.Modifier, entry.Target(controlType) ?? "", entry.Get(modifiers))) + .Where(applied => applied.Value is not null && applied.Property.Length > 0) + .ToArray(); + return result.Length == 0 ? global::System.Array.Empty() : result; + } + + /// Modifier name reported for the caption-derived default name. + internal const string DefaultAutomationNameModifier = "DefaultAutomationName"; + + // MarginInlineStart/End, PaddingInlineStart/End, BorderInlineStart: overlaid onto the + // property's existing value rather than replacing it (Reconciler.ApplyModifiers). + private static bool IsEdgeOverlay(string modifier) => modifier.Contains("Inline", StringComparison.Ordinal); + + /// + /// Merges the modifiers of a decorator chain (outermost first, innermost target last) in the + /// order Reactor applies them — the target's own, then each decorator's outward — so an outer + /// level's value replaces an inner one for the same property. Ends with the caption-derived + /// default name when it applies and the name in effect (the outermost level's) is absent or empty. + /// + internal static IReadOnlyList DescribeChain(IReadOnlyList chain, Type controlType, string? liveName) + { + var merged = new List(); + // The name in effect is the outermost level's (applied last); an empty one counts as + // none, so the caption default still applies even over an inner explicit name. + string? effectiveName = null; + for (int level = chain.Count - 1; level >= 0; level--) + { + if (chain[level].Modifiers is not { } modifiers) continue; + if (modifiers.AutomationName is { } levelName) effectiveName = levelName; + var levelRows = Describe(modifiers, controlType); + // Several modifiers can compose one property (Margin plus MarginInlineStart), and a + // level keeps all of its own contributors. Against inner levels: a modifier replaces + // its own earlier value; a whole-value modifier (Margin) rewrites the property and + // replaces every inner contributor, while an inline-edge overlay builds on it. + foreach (var m in levelRows) + { + bool wholeValue = !IsEdgeOverlay(m.Modifier); + merged.RemoveAll(prior => prior.Modifier == m.Modifier + || (wholeValue && string.Equals(prior.Property, m.Property, StringComparison.Ordinal))); + } + merged.AddRange(levelRows); + } + + if (effectiveName is not { Length: > 0 } && DescribeDefaultAutomationName(chain[^1], liveName) is { } defaultName) + merged.Add(defaultName); + return merged; + } + + /// + /// The caption-derived AutomationProperties.Name the reconciler writes when the + /// author sets none (Reconciler.ApplyDefaultAutomationName), or null when it does + /// not apply. + /// + /// Reported only while still equals the default: the + /// apply path yields to any existing name, so a different live value means the app (a + /// .Set(...) setter or code-behind) wrote it. A value the app set to exactly the + /// derived default (the caption as Reactor shortens it, not necessarily the full caption) is + /// indistinguishable from it and is reported as the default. + /// + internal static AppliedModifier? DescribeDefaultAutomationName(Element source, string? liveName) + { + // An explicit, non-empty .AutomationName(...) is reported as itself and suppresses the + // default; an empty one does not, because the apply path treats an empty name as absent. + if (source.Modifiers?.AutomationName is { Length: > 0 }) return null; + if (Reconciler.DefaultAutomationNameFromCaption(Reconciler.ResolveCaptionForElement(source)) is not { } name) + return null; + if (!string.Equals(liveName, name, StringComparison.Ordinal)) return null; + return new AppliedModifier(DefaultAutomationNameModifier, "AutomationProperties.Name", name); + } +} diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs new file mode 100644 index 000000000..51802b585 --- /dev/null +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -0,0 +1,377 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Microsoft.UI.Reactor.Core.Diagnostics; + +/// +/// How turns live values into text and text back into values: +/// formatting (quoted strings, summarised collections, truncation) and state parsing. Reactor +/// owns this so every inspector shows and parses values the same way and never has to hold a +/// live object to do it. Values are shown as they are: whether to hide any (a password field, +/// a token) is the inspector's call, not Reactor's. +/// +internal static class DiagnosticText +{ + internal const int MaxValueLength = 200; + + /// int, int?, List<TaskItem>: the type as C# reads it. + internal static string FriendlyTypeName(Type type) + { + if (Nullable.GetUnderlyingType(type) is { } inner) + return FriendlyTypeName(inner) + "?"; + if (type == typeof(nint)) return "nint"; + if (type == typeof(nuint)) return "nuint"; + if (!type.IsEnum) + { + var alias = Type.GetTypeCode(type) switch + { + TypeCode.Boolean => "bool", TypeCode.Byte => "byte", TypeCode.SByte => "sbyte", TypeCode.Char => "char", + TypeCode.Int16 => "short", TypeCode.UInt16 => "ushort", TypeCode.Int32 => "int", TypeCode.UInt32 => "uint", + TypeCode.Int64 => "long", TypeCode.UInt64 => "ulong", TypeCode.Single => "float", TypeCode.Double => "double", + TypeCode.Decimal => "decimal", TypeCode.String => "string", + _ => null, + }; + if (alias is not null) return alias; + } + if (type == typeof(object)) return "object"; + if (type.IsArray && type.GetElementType() is { } element) + return FriendlyTypeName(element) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; + var name = TypeName(type); + return type.IsGenericType + ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FriendlyTypeName))}>" + : name; + } + + /// A type's name without its generic arity suffix (List`1 → List). + internal static string TypeName(Type type) + { + var name = type.Name; + var tick = name.IndexOf('`'); + return tick > 0 ? name.Substring(0, tick) : name; + } + + /// + /// What a delegate runs, as the developer wrote it: Counter.Render, or + /// {fallback} in Counter.Render for a lambda or local function. + /// + internal static string DelegateName(Delegate? render, string fallback) + { + global::System.Reflection.MethodInfo? method; + try { method = render?.Method; } + catch (NotSupportedException) { method = null; } + if (method is null) return fallback; + + var owner = method.DeclaringType; + while (owner is not null && owner.Name.StartsWith('<')) + owner = owner.DeclaringType; + var name = method.Name; + if (name.StartsWith('<') && name.IndexOf('>') is var close and > 1) + return $"{fallback} in {(owner is null ? "" : TypeName(owner) + ".")}{name.Substring(1, close - 1)}"; + return owner is null ? name : $"{TypeName(owner)}.{name}"; + } + + /// A type a value can be typed as text: primitives, strings, enums, dates, times, GUIDs and their nullables. + internal static bool IsEditable(Type? type) + { + if (type is null) return false; + type = Nullable.GetUnderlyingType(type) ?? type; + // nint/nuint are primitive but have no text form the parser accepts. + return (type.IsPrimitive && type != typeof(nint) && type != typeof(nuint)) || type.IsEnum || type == typeof(string) || type == typeof(decimal) || + type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid); + } + + /// + /// A component value (prop, hook, context) as shown to the developer: collections + /// summarised, strings quoted, long text cut. + /// + internal static string Format(Type? declared, object? value) + { + if (value is not null && IsListLike(value)) + return CollectionSummary(value, declared); + if (value is string s) + return Quote(s); + return FormatPlain(value); + } + + /// + /// A value as an element record or modifier carries it: unquoted, long text cut. Used for + /// applied properties. + /// + internal static string FormatPlain(object? value) + { + // A collection (DescribedByRefs, …) is summarised by count, never by its own ToString(), + // which can enumerate it on the UI thread. + if (value is not null && IsListLike(value)) + return CollectionSummary(value, null); + + string text; + switch (value) + { + case null: text = "null"; break; + case string str: text = str; break; + case bool b: text = b ? "True" : "False"; break; + case Delegate d: text = "handler " + DelegateName(d, "lambda"); break; + case Element e: text = TypeName(e.GetType()); break; + case Microsoft.UI.Reactor.Input.ElementRef r: + text = r.ExpectedType is { } t ? $"ElementRef<{FriendlyTypeName(t)}>" : "ElementRef"; + break; + // Round-trip format, so a displayed date can be submitted back to TrySetState unchanged + // (keeps DateTime.Kind, the offset and fractional seconds). + case DateTime dt: text = dt.ToString("O", CultureInfo.InvariantCulture); break; + case DateTimeOffset dto: text = dto.ToString("O", CultureInfo.InvariantCulture); break; + case IFormattable f: + text = OwnText(f, () => f.ToString(null, CultureInfo.InvariantCulture)); + break; + default: + text = OwnText(value, value.ToString); + break; + } + return Truncate(text); + } + + /// + /// An app value's own text. App code can throw from ToString(); one bad value must + /// not fail a whole description, so it is shown as its type instead. + /// + private static string OwnText(object value, Func toString) + { + try { return toString() ?? ""; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) + { + return $"{FriendlyTypeName(value.GetType())} (ToString() threw {ex.GetType().Name})"; + } + } + + /// + /// True when every bit set in a [Flags] value belongs to a declared member, so a + /// numeric string cannot inject an undefined combination. + /// + private static bool OnlyDefinedFlags(Type enumType, object value) + { + ulong mask = 0; + foreach (var member in Enum.GetValuesAsUnderlyingType(enumType)) + mask |= ToBits(member); + return (ToBits(value) & ~mask) == 0; + } + + // Read through IConvertible (which a boxed enum implements for its underlying type) rather + // than by unboxing, and widen signed and unsigned alike so a value and the mask agree. + private static ulong ToBits(object value) + { + var convertible = (IConvertible)value; + return convertible.GetTypeCode() switch + { + TypeCode.SByte or TypeCode.Int16 or TypeCode.Int32 or TypeCode.Int64 + => unchecked((ulong)convertible.ToInt64(CultureInfo.InvariantCulture)), + TypeCode.Byte or TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64 + => convertible.ToUInt64(CultureInfo.InvariantCulture), + _ => ulong.MaxValue, + }; + } + + /// + /// Parses text to . No conversion beyond that: a value of any other + /// type is refused with a reason, because a complex value cannot be typed safely from text. + /// "null" sets a string or nullable to null. + /// + internal static bool TryParse(string text, Type type, out object? value, [NotNullWhen(false)] out string? error) + { + value = null; + error = null; + var underlying = Nullable.GetUnderlyingType(type); + var target = underlying ?? type; + // The editable-type gate comes first so "null" cannot clear a value that is + // otherwise not settable from text (a List, a record) — Editable says so. + if (!IsEditable(type)) + { + error = $"{FriendlyTypeName(type)} cannot be set from text; only numbers, characters, text, true/false, enums, dates, times, GUIDs and their nullables can be set"; + return false; + } + if (text == "null" && (underlying is not null || !type.IsValueType)) + return true; + + var culture = CultureInfo.InvariantCulture; + try + { + if (target == typeof(string)) + { + value = text; + return true; + } + if (target.IsEnum) + { + if (Enum.TryParse(target, text, ignoreCase: true, out var parsed) && + (Enum.IsDefined(target, parsed!) || (target.IsDefined(typeof(FlagsAttribute), false) && OnlyDefinedFlags(target, parsed!)))) + { + value = parsed; + return true; + } + error = $"'{text}' is not a valid {FriendlyTypeName(target)}; use one of {string.Join(", ", Enum.GetNames(target))}"; + return false; + } + if (target == typeof(bool)) + { + if (bool.TryParse(text, out var flag)) + { + value = flag; + return true; + } + error = $"'{text}' is not true or false"; + return false; + } + if (target == typeof(char)) + { + if (text.Length == 1) + { + value = text[0]; + return true; + } + error = $"'{text}' is not a single character"; + return false; + } + value = target == typeof(Guid) ? Guid.Parse(text) + : target == typeof(TimeSpan) ? TimeSpan.Parse(text, culture) + : target == typeof(DateTime) ? DateTime.Parse(text, culture, DateTimeStyles.RoundtripKind) + : target == typeof(DateTimeOffset) ? DateTimeOffset.Parse(text, culture) + : Convert.ChangeType(text, target, culture); + return true; + } + catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException or ArgumentException) + { + value = null; + error = $"'{text}' is not a valid {FriendlyTypeName(type)}"; + return false; + } + } + + /// + /// A component's props as name/value rows. A scalar props value is one row named Props. + /// + [UnconditionalSuppressMessage("Trimming", "IL2075", + Justification = "Diagnostics-only read of the props object's public properties. When trimming has removed that " + + "metadata the reader finds no properties and falls back to a single row naming the props type " + + "(never its ToString(), an arbitrary app method), so it degrades rather than fails.")] + internal static IReadOnlyList<(string Name, Type Type, object? Value)> PropRows(object? props, Type? declared) + { + // A typed component with null props still has a Props row, so it reads differently from a + // propless component. + if (props is null) + return declared is null + ? global::System.Array.Empty<(string, Type, object?)>() + : new[] { ("Props", declared, (object?)null) }; + var type = props.GetType(); + // A scalar (anything shown by its value, as Format and TryParse treat it) stays one row, + // so Format shows it whole instead of listing members (a Guid's none, a DateTime's + // Ticks/Kind/…). + if (IsScalar(type) || props is string or Enum || IsListLike(props)) + return new[] { ("Props", declared ?? type, (object?)props) }; + + var rows = new List<(string, Type, object?)>(); + // A public getter, not just CanRead: `public string X { private get; set; }` is not readable surface. + var readable = type.GetProperties(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance) + .Where(static p => p.Name != "EqualityContract" && p.GetIndexParameters().Length == 0 && p.GetMethod is { IsPublic: true }); + foreach (var property in readable) + { + object? value; + try + { + // A byref-like value (Span, ReadOnlyMemory.Span) cannot be boxed, so reflection + // cannot read it; its row says so instead of failing the whole description. + value = property.PropertyType.IsByRefLike + ? new OpaqueValue("") + : property.GetValue(props); + } + catch (Exception ex) when (ex is global::System.Reflection.TargetInvocationException or NotSupportedException) + { + // A throwing getter (or a read reflection does not support) still gets its row, so + // the member is visible; the exception text is not shown because it can carry the value. + value = new OpaqueValue(""); + } + rows.Add((property.Name, property.PropertyType, value)); + } + // No readable members (none declared, or their metadata trimmed away): name the type rather + // than fall back to ToString(), an arbitrary app method. + if (rows.Count == 0) + rows.Add(("Props", declared ?? type, new OpaqueValue($"{FriendlyTypeName(type)} (members unavailable)"))); + return rows; + } + + /// A value shown by a fixed description instead of its own text. + internal sealed class OpaqueValue(string text) + { + public override string ToString() => text; + } + + // A collection whose ToString() is object's (prints the type name) is summarised by count; a + // collection that formats itself (e.g. a record implementing IEnumerable) keeps its own text. + // Compared against the runtime type name rather than reflected on, so it is trim-safe. + // Any non-string sequence is a collection: its own ToString() is never called, because a + // custom one can enumerate it (lazy, side-effecting or infinite). + private static bool IsListLike(object value) => value is IEnumerable and not string; + + /// "List<T> (n items)": the type and advertised count, never the items. + private static string CollectionSummary(object value, Type? declared) + { + // A collection expression's runtime type is compiler-generated (<>z__ReadOnlyArray); name the declared one. + var shownType = value.GetType().Name.StartsWith('<') && declared is not null ? declared : value.GetType(); + var countText = TryCount(value) is int count + ? $"{count.ToString(CultureInfo.InvariantCulture)} {(count == 1 ? "item" : "items")}" + : "count unknown"; + return $"{FriendlyTypeName(shownType)} ({countText})"; + } + + /// + /// The item count a collection advertises, without enumerating it: a lazy sequence can be + /// expensive, infinite, or have side effects, so one that does not advertise a count reports + /// none (the rule Reactor.Devtools' state tool already follows). + /// + [UnconditionalSuppressMessage("Trimming", "IL2075", + Justification = "Diagnostics-only read of a public Count property (ICollection / IReadOnlyCollection implementers). " + + "When trimming has removed it the count is reported as unknown.")] + internal static int? TryCount(object value) + { + try + { + if (value is ICollection collection) return collection.Count; + var count = value.GetType().GetProperty("Count", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance); + // A public getter, not just a public property: a private getter is not advertised. + if (count is not null && count.PropertyType == typeof(int) && count.GetIndexParameters().Length == 0 + && count.GetMethod is { IsPublic: true }) + return (int?)count.GetValue(value); + return null; + } + // App code: a collection's Count can throw (directly, or wrapped by reflection). + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) + { + return null; + } + } + + private static string Quote(string s) + { + var escaped = s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal).Replace("\r", "\\r", StringComparison.Ordinal); + if (escaped.Length <= MaxValueLength) return "\"" + escaped + "\""; + // Cut the payload, not the quoted text, so a long value keeps both delimiters; never + // leave half of an escape sequence (an odd run of trailing backslashes) at the cut. + var cut = escaped.Substring(0, MaxValueLength); + int slashes = 0; + for (int i = cut.Length - 1; i >= 0 && cut[i] == '\\'; i--) slashes++; + if (slashes % 2 == 1) cut = cut.Substring(0, cut.Length - 1); + return "\"" + KeepWholeSurrogates(cut) + "...\""; + } + + private static string Truncate(string text) => + text.Length > MaxValueLength ? KeepWholeSurrogates(text.Substring(0, MaxValueLength)) + "..." : text; + + // A cut can land between the two halves of a surrogate pair (an emoji); drop the lone high + // half rather than emit malformed UTF-16. + private static string KeepWholeSurrogates(string cut) => + cut.Length > 0 && char.IsHighSurrogate(cut[^1]) ? cut.Substring(0, cut.Length - 1) : cut; + + /// A type shown by its value rather than its members: primitives (incl. nint/nuint), decimal, dates, times, GUIDs. + private static bool IsScalar(Type type) => + type.IsPrimitive || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) || type == typeof(Guid); +} diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs new file mode 100644 index 000000000..08b80afce --- /dev/null +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -0,0 +1,455 @@ +using Microsoft.UI.Xaml; + +namespace Microsoft.UI.Reactor.Core.Diagnostics; + +/// +/// A point-in-time description of one mounted component, for inspectors. Every value is text: +/// the snapshot holds no reference into the component, so it can be kept, serialized or sent +/// across a process boundary. +/// +/// The component's name: its class (Counter) or, for a render +/// function, where the function is defined (RenderEachTime in MainPage.Render, +/// Memo in MainPage.Render, render in App.Main for a host root). +/// "class", "function" or "memo". +/// True for a host's root component (the one passed to Mount). +/// The props of a Component<TProps> (or the props carried by its +/// element), one row per public property, or one row named Props for a scalar — or for a +/// props type whose members were trimmed away (NativeAOT), described as +/// <Type> (members unavailable). Empty for function components and propless classes. +/// The component's hooks in call order. is +/// the index takes. +/// The contexts the component reads through UseContext. +public sealed record ComponentSnapshot( + string Name, + string Kind, + bool IsRoot, + IReadOnlyList Props, + IReadOnlyList State, + IReadOnlyList Contexts); + +/// One prop, hook or context value of a , as text. +/// Position in its list. For and +/// it is the hook's call-order index. +/// The prop's name, or the context's declaration name (Context<T> +/// when it has none). Empty for hooks: hook names are not recorded. +/// For a prop, "prop". For a hook, "state", "reducer", +/// "ref", "memo", "effect", "context", "persisted", +/// "navigationLifecycle" or "unknown". For a context, "default" when the value +/// the component read equals the context's default value (no provider, or a provider supplying +/// that same value), otherwise "provided". +/// The value's type as C# spells it (int, int?, +/// List<TaskItem>). Nullable reference annotations are not visible at runtime, so a +/// string? reads string. Empty when the hook has no value. +/// The value as text: strings quoted, collections summarised as +/// List<T> (n items) ((count unknown) for a sequence that does not advertise +/// its count — it is never enumerated), long values cut at 200 characters. Values are not +/// redacted: an inspector that shows them where a secret must not appear hides them itself. +/// Empty when the hook has no value (effects). +/// True when can set it from +/// text: a state, reducer or persisted hook of a text-typed value. +/// For hooks: true when the most recent hot-reload pass +/// migrated this value to an edited type. Always false for props and contexts. +public sealed record DiagnosticValue( + int Index, + string Name, + string Kind, + string Type, + string Value, + bool Editable, + bool MigratedByHotReload); + +/// +/// A WinUI property Reactor writes on a control because the element that produced it sets a +/// common modifier, or because Reactor derived it (the default automation name). +/// +/// The modifier that carries the value: "Width", +/// "IsVisible", "Accessibility.HelpText", or "DefaultAutomationName" for +/// the name Reactor derives from a control's caption. +/// The WinUI property it is written to, as OwnerType.PropertyName: +/// "FrameworkElement.Width", "UIElement.Visibility", +/// "AutomationProperties.Name". +/// The element-side value as text. It can differ from the property value when +/// Reactor converts it (IsVisible → Visibility) or folds several modifiers into one +/// property (inline margins into Margin). +public sealed record AppliedProperty( + string Modifier, + string Property, + string Value); + +public static partial class ReactorDiagnostics +{ + /// + /// Describes the component hosted at : a component's wrapper + /// Border (every class, function and memo component is mounted into one), or a host's + /// root content (its rendered root control, the window content or ContentTarget it was + /// installed into, or a ReactorHostControl itself). Returns null for any other + /// element. Works with or without source mapping. + /// + /// + /// Must be called on the UI thread. + /// One element can anchor two components: when a root renders a component directly + /// (Render() => Component<Shell>()), the root's rendered control is + /// Shell's wrapper. The wrapper wins — the element describes Shell, the innermost + /// component it hosts — and / address the same + /// component. The root stays reachable through its other anchors: a ContentTarget, a + /// ReactorHostControl, or the dev-overlay wrapper. A host that installs straight into + /// Window.Content has none of those, so in that one shape its root is not reachable. + /// + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] + public static ComponentSnapshot? DescribeComponent(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + EnsureUIThread(element); + return ComponentHandle.TryResolve(element, out var handle) ? handle.Describe() : null; + } + + /// + /// Development-time state edit: parses to the value type of hook + /// of the component hosted at , writes + /// it, and — when the value changed — schedules a re-render exactly as the hook's own setter + /// would. + /// + /// A component wrapper or host root, as for . + /// The hook's . + /// Invariant-culture text: a number, a single character, true/false, an enum member + /// name, a date, time span or GUID, or the string itself (unquoted). null sets a + /// string or nullable to null. + /// Why the edit was refused, when this returns false. + /// True when the value was written (including when it was already equal). + /// + /// Only state, reducer and persisted hooks of a text-typed value can be set. + /// Must be called on the UI thread that renders the component. + /// + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] + public static bool TrySetState(UIElement element, int hookIndex, string text, out string? error) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(text); + EnsureUIThread(element); + if (!ComponentHandle.TryResolve(element, out var handle)) + { + error = "no Reactor component is hosted at this element"; + return false; + } + return handle.TrySetState(hookIndex, text, out error); + } + + /// + /// Schedules a re-render of the component hosted at , bypassing its + /// memoization (the path a state setter takes). + /// + /// + /// A re-render diffs the new element tree against the previous one, so it re-applies a + /// property only when the element's value for it changed: it does not restore a + /// property that was edited directly on the control while the element's value stayed the + /// same. Must be called on the UI thread that renders the component. + /// + /// False when no component is hosted at or it has never rendered. + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] + public static bool Rerender(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + EnsureUIThread(element); + return ComponentHandle.TryResolve(element, out var handle) && handle.Rerender(); + } + + /// + /// The WinUI properties Reactor writes on from the common modifiers + /// of the element that produced it, so an inspector can mark a property as set in C# (and + /// overwritten when the element's value changes). Empty when the control is not tagged with + /// its element — Reactor tags only controls something reads back (callbacks, a key, or a + /// stamped source location), so turn on + /// for full coverage. + /// + /// + /// Covers and , plus the + /// AutomationProperties.Name Reactor derives from a captioned control + /// (Button("Save"), CheckBox("Agree"), …) when the author sets none. That one is + /// reported as "DefaultAutomationName", and only while the live name still equals it; a + /// different live name was written by the app. Properties a control's own element record sets + /// (TextBlock("hi") → Text) are not listed. Must be called on the UI thread. + /// + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] + public static IReadOnlyList GetAppliedProperties(UIElement control) + { + ArgumentNullException.ThrowIfNull(control); + EnsureUIThread(control); + var tag = Reconciler.GetElementTag(control); + if (tag is null) return global::System.Array.Empty(); + var chain = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(tag); + if (Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, chain[^1])) + return global::System.Array.Empty(); + + var described = AppliedModifierMap.DescribeChain( + chain, control.GetType(), Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(control)); + var applied = new AppliedProperty[described.Count]; + for (int i = 0; i < applied.Length; i++) applied[i] = ToText(described[i]); + return applied; + } + + internal static AppliedProperty ToText(AppliedModifier m) + => new(m.Modifier, m.Property, DiagnosticText.FormatPlain(m.Value)); + + /// + /// Every public member is UI-thread-affine: component tables are unsynchronized and + /// hook cells are owned by the render thread. Off-thread calls fail loudly instead of + /// racing. The supplied element's own dispatcher decides (it is set for every live WinUI + /// element, including in an embedded ReactorHostControl with no ReactorApp.Run, + /// and is right per window thread); ThreadAffinity's process-wide rule covers the rest. + /// + internal static void EnsureUIThread(UIElement element) + { + if (element.DispatcherQueue is { } queue) + { + if (!queue.HasThreadAccess) + throw new InvalidOperationException( + "ReactorDiagnostics must be called on the UI thread that owns the element. " + + "Use element.DispatcherQueue.TryEnqueue(...) to marshal the call."); + return; + } + Microsoft.UI.Reactor.Hosting.ThreadAffinity.ThrowIfNotOnUIThread("ReactorDiagnostics component inspection"); + } +} + +/// +/// Internal resolution of a component for : the render context +/// plus identity. Never exposed — the public surface only returns text snapshots of it. +/// +internal sealed class ComponentHandle +{ + private readonly RenderContext _context; + private readonly Func _isAlive; + + internal ComponentHandle(string kind, bool isRoot, string name, RenderContext context, + object? props, Type? propsType, Func isAlive) + { + Kind = kind; + IsRoot = isRoot; + Name = name; + _context = context; + Props = props; + PropsType = propsType; + _isAlive = isAlive; + } + + internal string Kind { get; } + internal bool IsRoot { get; } + internal string Name { get; } + internal object? Props { get; } + internal Type? PropsType { get; } + internal bool IsMounted => _isAlive(); + + /// + /// Resolves the component wrapper or host root at . Must run on the + /// UI thread (the node tables are not locked). + /// + internal static bool TryResolve(UIElement element, out ComponentHandle handle) + { + if (Reconciler.TryFindComponentNode(element, out var owner, out var node) + && (node.Component is not null || node.Context is not null)) + { + handle = FromNode(node, () => owner.IsLiveComponentNode(element, node)); + return true; + } + if (Reconciler.TryFindRootComponent(element, out var root)) + { + handle = FromRoot(root); + return true; + } + handle = null!; + return false; + } + + internal static ComponentHandle FromNode(Reconciler.ComponentNode node, Func isAlive) + { + var component = node.Component; + var context = component?.Context ?? node.Context + ?? throw new InvalidOperationException("Component node has no render context."); + var element = node.Element; + + if (component is not null) + { + var receiver = component as IPropsReceiver; + var props = receiver is not null ? receiver.PropsBoxed : (element as ComponentElement)?.Props; + return new ComponentHandle("class", isRoot: false, DiagnosticText.TypeName(component.GetType()), + context, props, receiver?.PropsType ?? props?.GetType(), isAlive); + } + + var memo = element is MemoElement; + Delegate? render = element switch + { + FuncElement f => f.RenderFunc, + MemoElement m => m.RenderFunc, + _ => null, + }; + return new ComponentHandle(memo ? "memo" : "function", isRoot: false, + DiagnosticText.DelegateName(render, memo ? "Memo" : "RenderEachTime"), + context, props: null, propsType: null, isAlive); + } + + internal static ComponentHandle FromRoot(in RootComponentSource root) + { + var component = root.Component; + var context = component?.Context ?? root.FuncContext + ?? throw new InvalidOperationException("Root source has no render context."); + if (component is not null) + { + var receiver = component as IPropsReceiver; + return new ComponentHandle("class", isRoot: true, DiagnosticText.TypeName(component.GetType()), + context, receiver?.PropsBoxed, receiver?.PropsType, root.IsAlive); + } + return new ComponentHandle("function", isRoot: true, DiagnosticText.DelegateName(root.RenderFunc, "render"), + context, props: null, propsType: null, root.IsAlive); + } + + internal ComponentSnapshot Describe() + { + var contexts = DescribeContexts(); + return new ComponentSnapshot(Name, Kind, IsRoot, DescribeProps(), DescribeHooks(contexts), contexts); + } + + internal IReadOnlyList DescribeProps() + { + var rows = DiagnosticText.PropRows(Props, PropsType); + if (rows.Count == 0) return global::System.Array.Empty(); + var values = new DiagnosticValue[rows.Count]; + for (int i = 0; i < rows.Count; i++) + { + var (name, type, value) = rows[i]; + values[i] = new DiagnosticValue(i, name, "prop", DiagnosticText.FriendlyTypeName(type), + DiagnosticText.Format(type, value), Editable: false, MigratedByHotReload: false); + } + return values; + } + + internal IReadOnlyList DescribeHooks(IReadOnlyList? contexts = null) + { + int count = _context.DiagnosticHookCount; + if (count == 0) return global::System.Array.Empty(); + var values = new DiagnosticValue[count]; + for (int i = 0; i < count; i++) + { + var cell = _context.DiagnosticHookAt(i); + var valueType = cell.DiagnosticValueType; + string text = ""; + if (cell.DiagnosticKind == HookKind.Context && contexts?.FirstOrDefault(c => c.Index == i) is { } contextRow) + { + // Already formatted for Contexts: reuse it, so one snapshot runs app formatting + // code once and both rows agree. + text = contextRow.Value; + } + else if (cell.DiagnosticKind is not (HookKind.Effect or HookKind.NavigationLifecycle or HookKind.Unknown)) + { + text = DiagnosticText.Format(valueType, cell.DiagnosticValue); + } + values[i] = new DiagnosticValue(i, "", HookKindText(cell.DiagnosticKind), + valueType is null ? "" : DiagnosticText.FriendlyTypeName(valueType), text, + Editable: cell.DiagnosticCanSet && DiagnosticText.IsEditable(valueType), + cell.Migrated); + } + return values; + } + + internal IReadOnlyList DescribeContexts() + { + List? result = null; + int count = _context.DiagnosticHookCount; + for (int i = 0; i < count; i++) + { + if (_context.DiagnosticHookAt(i) is not RenderContext.ContextHookState { Context: { } ctx } hook) + continue; + var name = ctx.DiagnosticName is { Length: > 0 } declared + ? declared + : $"Context<{DiagnosticText.FriendlyTypeName(ctx.ValueType)}>"; + var value = hook.LastValue; + var fallback = ctx.DefaultValueBoxed; + bool isDefault; + // App code: a value's Equals override can throw; one bad value must not fail the description. + try { isDefault = ReferenceEquals(value, fallback) || (value is not null && value.Equals(fallback)); } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) { isDefault = false; } + (result ??= new()).Add(new DiagnosticValue(i, name, isDefault ? "default" : "provided", + DiagnosticText.FriendlyTypeName(ctx.ValueType), DiagnosticText.Format(ctx.ValueType, value), + Editable: false, MigratedByHotReload: false)); + } + return result is null ? global::System.Array.Empty() : result; + } + + internal bool TrySetState(int hookIndex, string text, out string? error) + { + EnsureRenderThread(); + if (!IsMounted) + { + error = $"<{Name}> is no longer mounted"; + return false; + } + if ((uint)hookIndex >= (uint)_context.DiagnosticHookCount) + { + error = $"<{Name}> has no hook {hookIndex}"; + return false; + } + var cell = _context.DiagnosticHookAt(hookIndex); + if (!cell.DiagnosticCanSet) + { + error = $"hook {hookIndex} of <{Name}> has kind '{HookKindText(cell.DiagnosticKind)}'; only state, reducer and persisted hooks can be set"; + return false; + } + var valueType = cell.DiagnosticValueType; + if (valueType is null) + { + error = $"hook {hookIndex} of <{Name}> has no value type"; + return false; + } + if (!DiagnosticText.TryParse(text, valueType, out var parsed, out var parseError)) + { + error = parseError; + return false; + } + if (!_context.TrySetHookValueForDiagnostics(hookIndex, parsed)) + { + error = $"hook {hookIndex} of <{Name}> refused the value"; + return false; + } + error = null; + return true; + } + + internal bool Rerender() + { + EnsureRenderThread(); + return IsMounted && _context.RequestRerenderForDiagnostics(); + } + + private void EnsureRenderThread() + { + if (!_context.IsOnRenderThread) + throw new InvalidOperationException( + "ReactorDiagnostics writes must run on the UI thread that renders the component."); + } + + internal static string HookKindText(HookKind kind) => kind switch + { + HookKind.State => "state", + HookKind.Reducer => "reducer", + HookKind.Ref => "ref", + HookKind.Memo => "memo", + HookKind.Effect => "effect", + HookKind.Context => "context", + HookKind.Persisted => "persisted", + HookKind.NavigationLifecycle => "navigationLifecycle", + _ => "unknown", + }; +} + +/// Which hook produced a hook cell. Internal: the public surface reports it as text. +internal enum HookKind +{ + Unknown, + State, + Reducer, + Ref, + Memo, + Effect, + Context, + Persisted, + NavigationLifecycle, +} diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Hosts.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Hosts.cs index 4688c84c0..3e4a04667 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Hosts.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Hosts.cs @@ -293,6 +293,13 @@ internal interface IReactorDiagnosticHost /// on its own UI thread. A disposed host does nothing. /// void TagComponentBoundaries(); + + /// + /// The host's reconciler, for component lookup (Reconciler.TryFindComponentNode); + /// null once the host is disposed. Read on the caller's thread; the lookup itself filters + /// by the reconciler's owning dispatcher before touching it. + /// + Reconciler? DiagnosticReconciler { get; } } /// diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs new file mode 100644 index 000000000..63dd904da --- /dev/null +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -0,0 +1,181 @@ +using Microsoft.UI.Reactor.Input; +using Microsoft.UI.Xaml; + +namespace Microsoft.UI.Reactor.Core.Diagnostics; + +/// +/// One reference a control declares to another control — resolved, or still pending because the +/// target has not mounted. +/// +/// The referencing property: "LabeledBy", "DescribedBy", +/// "FlowsTo", "FlowsFrom", "XYFocusUp", "XYFocusDown", +/// "XYFocusLeft", "XYFocusRight" or "ToolTipPlacementTarget". A control +/// descriptor's or binding's reference carries no author-visible name and reports +/// "reference#N" / "binding#N". +/// True for a list-valued reference (DescribedBy, FlowsTo, +/// FlowsFrom, a descriptor's ReferenceList); each target is its own entry. +/// Position of this target within a list-valued reference (0 for a scalar one), +/// counting references only: a null entry in the authored list is not a reference and is skipped. +/// The AutomationId the author passed to .LabeledBy("id"); +/// null for an ElementRef reference. +/// For a typed ElementRef<T>, T as C# spells +/// it (Button); otherwise null. +/// True when the target is mounted. +/// The mounted control the reference resolves to; null while pending. +/// Unlike the rest of the diagnostics surface this is a live WinUI control, not text — use it +/// in-process, on the UI thread, to locate the target (e.g. to inspect or highlight it). +public sealed record ReferenceEdgeSnapshot( + string Property, + bool IsList, + int Index, + string? TargetAutomationId, + string? ExpectedTargetTypeName, + bool IsResolved, + UIElement? Target); + +public static partial class ReactorDiagnostics +{ + /// + /// The references declares to other controls — including pending + /// ones whose target has not mounted yet, for which the WinUI property itself (e.g. + /// XYFocusRight, AutomationProperties.LabeledBy) still reads null. Grouped by + /// property in a fixed order (control-descriptor references, then bindings, then the + /// modifier references), then list position; both LabeledBy forms are adjacent, the + /// ElementRef one first. + /// + /// + /// ElementRef references (modifiers such as .LabeledBy(ref) or + /// .XYFocusRight(ref), control descriptors and bindings) are always reported. An + /// AutomationId .LabeledBy("id") is reported while pending; once resolved it is reported + /// only while the control is loaded and tagged with its element (turn source mapping on). Reads existing + /// reconciler state on demand. Must be called on the UI thread. + /// + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] + public static IReadOnlyList GetReferenceEdges(UIElement control) + { + ArgumentNullException.ThrowIfNull(control); + EnsureUIThread(control); + + Reconciler.ReactorState? state = null; + if (control is FrameworkElement fe) Reconciler.TryGetReactorState(fe, out state); + var bag = state?.ReferenceEdges; + var pendingLabeledBy = state?.PendingLabeledBy; + + // The resolved AutomationId form leaves no bookkeeping behind, so recover what the + // author wrote from the element tag (present when tagged) and confirm the live + // LabeledBy is the control it names rather than one an ElementRef edge wrote. + string? authoredLabeledBy = null; + UIElement? resolvedLabeledBy = null; + if (pendingLabeledBy is null + // An unmounted (unpooled) control keeps its tag, but its reference state was torn + // down; only a control still in the live tree has an authored edge to recover. + && control is FrameworkElement { IsLoaded: true } + && Reconciler.GetElementTag(control) is { } tag + && Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(tag) is var chain + && !Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, chain[^1]) + && ReferenceEdgeMap.EffectiveLabeledById(chain) is { } id) + { + authoredLabeledBy = id; + // The live property can outlive its target: an unmounted label stays referenced until + // something rewrites LabeledBy. Only a target still loaded in the control's own tree counts. + if (Microsoft.UI.Xaml.Automation.AutomationProperties.GetLabeledBy(control) is FrameworkElement { IsLoaded: true } live + && ReferenceEquals(live.XamlRoot, control.XamlRoot) + && string.Equals(Microsoft.UI.Xaml.Automation.AutomationProperties.GetAutomationId(live), id, StringComparison.Ordinal)) + resolvedLabeledBy = live; + } + + return ReferenceEdgeMap.Describe(bag, pendingLabeledBy, authoredLabeledBy, resolvedLabeledBy); + } +} + +/// +/// Pure projection of the reconciler's reference bookkeeping onto +/// ; split from +/// so it is testable without a live control. +/// +internal static class ReferenceEdgeMap +{ + /// + /// The AutomationId .LabeledBy("id") in effect across a decorator chain (outermost + /// first): decorators apply their modifiers after their target's, so the outermost level that + /// sets one wins. + /// + internal static string? EffectiveLabeledById(IReadOnlyList chain) + { + foreach (var element in chain) + if (element.Modifiers?.Accessibility?.LabeledBy is { } id) return id; + return null; + } + + internal static IReadOnlyList Describe( + ReferenceEdgeBag? bag, + string? pendingLabeledBy, + string? authoredLabeledBy, + UIElement? resolvedLabeledBy) + { + List<(int Slot, ReferenceEdgeSnapshot Edge)>? result = null; + + if (bag is not null) + { + foreach (var (slot, edge) in bag.Edges) + { + // A null cell is an unset / torn-down slot, not an active reference. + if (edge.Cell is not { } cell) continue; + (result ??= new()).Add((slot, ForCell(slot, isList: false, index: 0, cell))); + } + + foreach (var (slot, listEdge) in bag.ListEdges) + { + // The authored list, not the deduplicated subscription set, so order and repeats + // match what the author wrote; Index is the position among its references. + var authored = listEdge.Authored; + if (authored is null) continue; + for (int i = 0; i < authored.Count; i++) + (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, authored[i]))); + } + } + + if ((pendingLabeledBy ?? authoredLabeledBy) is { } automationId) + { + var target = pendingLabeledBy is null ? resolvedLabeledBy : null; + (result ??= new()).Add((ReferenceSlots.ModifierRef_LabeledById, new ReferenceEdgeSnapshot( + "LabeledBy", + IsList: false, + Index: 0, + TargetAutomationId: automationId, + ExpectedTargetTypeName: null, + IsResolved: target is not null, + Target: target))); + } + + if (result is null) return global::System.Array.Empty(); + // Group by public property: the AutomationId LabeledBy pseudo-slot sorts with the + // ElementRef LabeledBy slot (after it), so no other property lands between them. + static (int Group, int Sub) Key(int slot) => slot == ReferenceSlots.ModifierRef_LabeledById + ? (ReferenceSlots.ModifierRef_LabeledBy, 1) + : (slot, 0); + result.Sort(static (a, b) => + { + var (ka, kb) = (Key(a.Slot), Key(b.Slot)); + if (ka.Group != kb.Group) return ka.Group.CompareTo(kb.Group); + if (ka.Sub != kb.Sub) return ka.Sub.CompareTo(kb.Sub); + return a.Edge.Index.CompareTo(b.Edge.Index); + }); + var edges = new ReferenceEdgeSnapshot[result.Count]; + for (int i = 0; i < edges.Length; i++) edges[i] = result[i].Edge; + return edges; + } + + private static ReferenceEdgeSnapshot ForCell(int slot, bool isList, int index, ElementRef cell) + { + var target = cell.Current; + return new ReferenceEdgeSnapshot( + ReferenceSlots.Label(slot), + isList, + index, + TargetAutomationId: null, + cell.ExpectedType is { } t ? DiagnosticText.FriendlyTypeName(t) : null, + IsResolved: target is not null, + target); + } +} diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs new file mode 100644 index 000000000..5beb79c31 --- /dev/null +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -0,0 +1,131 @@ +using Microsoft.UI.Xaml; + +namespace Microsoft.UI.Reactor.Core; + +/// +/// Component lookup for : maps a mounted +/// component's wrapper (or a host's root content) back to the +/// reconciler state that owns it. +/// +/// Each host owns its own , and a component node lives +/// in that reconciler's private _componentNodes table, so a lookup walks every live +/// host. It reuses the host registry behind ReactorDiagnostics.GetHosts() +/// () rather than keeping a second one, so +/// nothing is recorded per component or per render and every query is resolved on demand. +/// A reconciler used directly, without a host, is not inspected. +/// +public sealed partial class Reconciler +{ + /// + /// Set by the owning host (ReactorHost / ReactorHostControl). The root + /// component is rendered by the host, not mounted through , + /// so it has no _componentNodes entry; this resolver is how a diagnostics query + /// reaches it. Returns null when element is not this host's root anchor. + /// + internal Func? DiagnosticsRootResolver { get; set; } + + /// + /// The owning host's UI dispatcher, set alongside . + /// Component rerenders marshal onto it (hosts can live on different UI threads, so the + /// process-wide ReactorApp.UIDispatcher may be another window's), and diagnostics + /// lookups skip every reconciler whose dispatcher is not the calling thread's before + /// touching its unsynchronized tables or controls. A reconciler with no dispatcher recorded + /// is not inspected, and its rerenders fall back to ReactorApp.UIDispatcher. + /// + internal Microsoft.UI.Dispatching.DispatcherQueue? OwningDispatcher { get; set; } + + private bool IsInspectableFromThisThread => OwningDispatcher is { HasThreadAccess: true }; + + /// The reconcilers of live hosts that the calling thread may inspect. + private static IEnumerable InspectableReconcilers() + => Diagnostics.ReactorHostRegistry.Snapshot() + .Select(static host => host.DiagnosticReconciler) + .Where(static r => r is { IsInspectableFromThisThread: true })!; + /// + /// Finds the component whose wrapper is in any live + /// reconciler. Must be called on the UI thread (the node tables are not locked). + /// + internal static bool TryFindComponentNode(UIElement element, out Reconciler owner, out ComponentNode node) + { + foreach (var r in InspectableReconcilers()) + { + if (r._componentNodes.TryGetValue(element, out var found)) + { + owner = r; + node = found; + return true; + } + } + owner = null!; + node = null!; + return false; + } + + /// Finds the host root component anchored at . + internal static bool TryFindRootComponent(UIElement element, out RootComponentSource root) + { + foreach (var r in InspectableReconcilers()) + { + if (r.DiagnosticsRootResolver?.Invoke(element) is { } found) + { + root = found; + return true; + } + } + root = default; + return false; + } + + /// + /// True while still maps to — false + /// once the component unmounts or its wrapper is re-used for a different instance. + /// + internal bool IsLiveComponentNode(UIElement wrapper, ComponentNode node) + => !_disposedForDiagnostics + && _componentNodes.TryGetValue(wrapper, out var current) + && ReferenceEquals(current, node); + + private bool _disposedForDiagnostics; +} + +/// +/// A host's root component as seen by diagnostics. Exactly one of +/// (a class component passed to Mount(Component)) or +/// + (a render function passed to +/// Mount(Func<RenderContext, Element>)) is set. +/// +internal readonly record struct RootComponentSource( + Component? Component, + RenderContext? FuncContext, + Delegate? RenderFunc, + UIElement? RenderedControl, + Element? RenderedElement, + Func IsAlive); + +/// +/// The root that produced a host's current control, snapshotted when its render pass starts and +/// published with the control that pass commits. Mount swaps the requested root before +/// the queued render replaces the control (and a root can Mount from its own Render()), so +/// diagnostics resolve against this rather than the requested root. Cleared when that root is +/// retired, so a retired root is never described or written. +/// +internal readonly struct RenderedRoot +{ + internal RenderedRoot(Component? component, RenderContext? funcContext, Delegate? renderFunc) + { + Component = component; + FuncContext = component is null ? funcContext : null; + RenderFunc = component is null ? renderFunc : null; + } + + internal Component? Component { get; } + internal RenderContext? FuncContext { get; } + internal Delegate? RenderFunc { get; } + internal bool IsEmpty => Component is null && FuncContext is null; + + // Identity of the root, not its render delegate: every Mount allocates a new Component or + // FuncContext and nothing swaps a root's delegate in place, so the context identifies a function + // root on its own (and lets retirement match it without the delegate). + internal bool SameRootAs(in RenderedRoot other) + => ReferenceEquals(Component, other.Component) && ReferenceEquals(FuncContext, other.FuncContext); +} diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 35b1830f5..5b2cd9b79 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -875,6 +875,8 @@ internal sealed class ReactorState // on a mismatch. Cleared on resolve, on unset, and everywhere the echo // state is cleared (pool return / ClearCurrentEventHandlers / // DetachReactorState) so a stale arm can't fire into a later lifecycle. + // A Loaded resolution that still finds no target leaves it set: the request + // is genuinely still pending, which ReactorDiagnostics.GetReferenceEdges reports. public string? PendingLabeledBy; // Spec 042 Phase 1 — keyed-list reconciliation state. Set when the // host element is a templated items control (ListView/GridView/ @@ -1938,6 +1940,7 @@ internal static void WireReferenceListEdge( var edge = GetOrCreateReferenceListEdge(ctrl, slot); edge.Recompute = recompute; edge.Clear = clearTarget; // retained so teardown can empty the target list (CR-002) + edge.Authored = cells; edge.Handler ??= _ => edge.Recompute?.Invoke(ctrl); var next = new List(); @@ -1971,8 +1974,12 @@ internal static void WireReferenceListEdge( internal static void TeardownReferenceEdges(FrameworkElement ctrl) { - if (ctrl.GetValue(ReactorAttached.StateProperty) is not ReactorState state - || state.ReferenceEdges is null) + if (ctrl.GetValue(ReactorAttached.StateProperty) is not ReactorState state) + return; + // A deferred .LabeledBy("id") that never resolved is reference state too: a retained, + // unpooled control must not keep reporting it after unmount. + state.PendingLabeledBy = null; + if (state.ReferenceEdges is null) return; foreach (var edge in state.ReferenceEdges.Edges.Values) @@ -1994,6 +2001,7 @@ internal static void TeardownReferenceEdges(FrameworkElement ctrl) // Empty the target relationship list (DescribedBy / FlowsTo / FlowsFrom) — CR-002. edge.Clear?.Invoke(ctrl); edge.Cells.Clear(); + edge.Authored = null; edge.Handler = null; edge.Recompute = null; edge.Clear = null; @@ -2815,7 +2823,7 @@ private void ReconcileComponent(Element oldEl, Element newEl, UIElement control, /// before invoking the parent requestRerender, so the memo check is bypassed. /// Captures the node directly to avoid accessing _componentNodes from background threads. /// - private static Action CreateComponentRerender(ComponentNode node, Action requestRerender) + private Action CreateComponentRerender(ComponentNode node, Action requestRerender) { return () => { @@ -2831,11 +2839,13 @@ private static Action CreateComponentRerender(ComponentNode node, Action request // SECURITY (TASK-063): if invoked off the UI thread (e.g., // setState(threadSafe:true) firing from a worker, or a worker // that owns its own DispatcherQueue), marshal onto the UI - // dispatcher captured at host bring-up so we don't race the - // reconciler and ElementPool from a background thread. - // A non-null GetForCurrentThread() doesn't imply UI affinity — - // the only authoritative check is HasThreadAccess on the UI DQ. - var uiDq = Microsoft.UI.Reactor.ReactorApp.UIDispatcher; + // dispatcher so we don't race the reconciler and ElementPool from + // a background thread. That is this reconciler's owning thread + // (its host's), not the process-wide one: hosts on a second UI + // thread must render on theirs. A non-null GetForCurrentThread() + // doesn't imply UI affinity — the only authoritative check is + // HasThreadAccess on the UI DQ. + var uiDq = OwningDispatcher ?? Microsoft.UI.Reactor.ReactorApp.UIDispatcher; bool onUiThread = false; if (uiDq is not null) { @@ -3920,13 +3930,24 @@ control is FrameworkElement fe public static void ApplyDefaultAutomationName(FrameworkElement fe, string? caption) { if (fe is null) return; - if (string.IsNullOrWhiteSpace(caption)) return; + if (DefaultAutomationNameFromCaption(caption) is not { } trimmed) return; var existing = Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(fe); if (!string.IsNullOrEmpty(existing)) return; - var trimmed = caption.Length > 100 ? caption.Substring(0, 100) : caption; Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(fe, trimmed); } + /// + /// The name / + /// write for (null when they write nothing). Shared with + /// ReactorDiagnostics.GetAppliedProperties so the reported default cannot drift + /// from the one applied. + /// + internal static string? DefaultAutomationNameFromCaption(string? caption) + { + if (string.IsNullOrWhiteSpace(caption)) return null; + return caption.Length > 100 ? caption.Substring(0, 100) : caption; + } + // Update variant: a label change ("+ 1" → "+ 2") should update UIA Name as // long as the author didn't override it. We can't distinguish "author set // it to the previous caption" from "our default set it" without tracking @@ -3942,16 +3963,26 @@ public static void ApplyDefaultAutomationName(FrameworkElement fe, string? capti public static void UpdateDefaultAutomationName(FrameworkElement fe, string? oldCaption, string? newCaption) { if (fe is null) return; - if (string.IsNullOrWhiteSpace(newCaption)) return; + if (DefaultAutomationNameFromCaption(newCaption) is not { } trimmed) return; var current = Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(fe); - bool authorOverride = - !string.IsNullOrEmpty(current) && - (oldCaption is null || !string.Equals(current, oldCaption, StringComparison.Ordinal)); - if (authorOverride) return; - var trimmed = newCaption.Length > 100 ? newCaption.Substring(0, 100) : newCaption; + if (!MayReplaceDefaultAutomationName(current, oldCaption)) return; Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(fe, trimmed); } + /// + /// True when the live name is Reactor's own default for (or + /// empty), so a caption change may replace it. The default for a long caption is its + /// truncated form (), so both the full and the + /// truncated caption count as Reactor's; any other value is the author's and is kept. + /// + internal static bool MayReplaceDefaultAutomationName(string? current, string? oldCaption) + { + if (string.IsNullOrEmpty(current)) return true; + if (oldCaption is null) return false; + return string.Equals(current, oldCaption, StringComparison.Ordinal) + || string.Equals(current, DefaultAutomationNameFromCaption(oldCaption), StringComparison.Ordinal); + } + internal static string? ExtractElementCaption(Element? element) => element switch { TextBlockElement te => te.Content, @@ -5652,10 +5683,15 @@ void OnLoaded(object sender, RoutedEventArgs _) fe.Loaded -= OnLoaded; if (!TryGetReactorState(fe, out var pending) || pending.PendingLabeledBy != labelId) return; - pending.PendingLabeledBy = null; var deferred = FindByAutomationId(fe, labelId); if (deferred is not null) + { + pending.PendingLabeledBy = null; Microsoft.UI.Xaml.Automation.AutomationProperties.SetLabeledBy(fe, deferred); + } + // Not found: the request stays recorded as pending (still unresolved), so + // ReactorDiagnostics.GetReferenceEdges keeps reporting it. Nothing retries it + // here; a later render that changes or drops the id, or pool return, retires it. } fe.Loaded += OnLoaded; } @@ -6913,6 +6949,8 @@ public void Dispose() /// internal global::System.Runtime.ExceptionServices.ExceptionDispatchInfo? DisposeCollectingPropagation() { + // Hosts dispose through here rather than Dispose(); a disposed reconciler's nodes are dead. + _disposedForDiagnostics = true; // Issue #1291: with a RenderErrorHandler configured, every effect cleanup runs and // each failure is reported (Source = Cleanup). With no handler the first throwing // cleanup escapes as before. diff --git a/src/Reactor/Core/RenderContext.cs b/src/Reactor/Core/RenderContext.cs index 0663d6c08..4b7118380 100644 --- a/src/Reactor/Core/RenderContext.cs +++ b/src/Reactor/Core/RenderContext.cs @@ -1199,6 +1199,10 @@ public T UseContext(Context context) "Hooks must be called in the same order every render."); _hookIndex++; + // Refreshed every render, like LastValue: a stable slot can be handed a different + // Context instance, and readers (memo change detection, diagnostics) must see the + // one this render actually read. + hook.Context = context; var value = _contextScope is not null ? _contextScope.Read(context) : context.DefaultValue; @@ -2624,72 +2628,78 @@ private static bool DepsEqual(object[] prev, object[] next) /// we have access; devtools code consumes the boxed values and does the /// JSON shaping. Must be called on the UI dispatcher. /// - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "SnapshotHooks uses reflection on internal hook state types.")] - [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "SnapshotHooks uses reflection on internal hook state types.")] internal IReadOnlyList SnapshotHooks() { + // Built on the cells' diagnostic members (no reflection): the raw value keeps the + // shape reactor.state has always reported (a ref hook's Ref box, a reducer as useState). var list = new List(_hooks.Count); for (int i = 0; i < _hooks.Count; i++) { var h = _hooks[i]; - var t = h.GetType(); - string hookName; - Type? valueType = null; - object? value = null; - - if (t.IsGenericType) + var kind = h.DiagnosticKind; + string hookName = kind switch { - var def = t.GetGenericTypeDefinition(); - if (def == typeof(ValueHookState<>)) - { - valueType = t.GetGenericArguments()[0]; - value = t.GetField("Value")!.GetValue(h); - // UseRef uses the same cell, but its value is a Ref. - hookName = valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(Ref<>) + // reactor.state has always named a value cell by its stored type: a Ref is "useRef". + HookKind.State or HookKind.Reducer or HookKind.Ref => + h.DiagnosticRawValueType is { IsGenericType: true } raw && raw.GetGenericTypeDefinition() == typeof(Ref<>) ? "useRef" - : "useState"; - } - else if (def == typeof(MemoHookState<>)) - { - valueType = t.GetGenericArguments()[0]; - value = t.GetField("Value")!.GetValue(h); - hookName = "useMemo"; - } - else if (def == typeof(PersistedHookState<>)) - { - valueType = t.GetGenericArguments()[0]; - value = t.GetField("Value")!.GetValue(h); - hookName = "usePersisted"; - } - else - { - hookName = t.Name; - } - } - else if (h is EffectHookState) - { - hookName = "useEffect"; - } - else if (h is ContextHookState ch) - { - hookName = "useContext"; - value = ch.LastValue; - valueType = value?.GetType(); - } - else if (h is NavigationLifecycleHookState) - { - hookName = "useNavigationLifecycle"; - } - else - { - hookName = t.Name; - } - + : "useState", + HookKind.Memo => "useMemo", + HookKind.Persisted => "usePersisted", + HookKind.Effect => "useEffect", + HookKind.Context => "useContext", + HookKind.NavigationLifecycle => "useNavigationLifecycle", + _ => h.GetType().Name, + }; + var value = h.DiagnosticRawValue; + var valueType = kind == HookKind.Context ? value?.GetType() : h.DiagnosticRawValueType; list.Add(new HookSnapshot(i, hookName, valueType, value, h.Migrated)); } return list; } + // ── Diagnostics (ReactorDiagnostics.Components) ────────────────── + // Read/write seams for out-of-process inspectors. Pure on-demand: nothing + // here runs during a render, so apps that never inspect pay nothing. + + internal int DiagnosticHookCount => _hooks.Count; + + internal HookState DiagnosticHookAt(int index) => _hooks[index]; + + /// + /// True when the caller is on the thread that last rendered this context, or + /// the context has never rendered (a headless/test context, where there is no + /// thread to be affine to yet). + /// + internal bool IsOnRenderThread => + _requestRerender is null || Environment.CurrentManagedThreadId == _uiThreadId; + + /// + /// Writes a hook cell by index and, when the value changed, requests a + /// re-render through the same callback the hook's own setter uses — so the + /// owning component (and its ancestors, via the SelfTriggered chain) re-render + /// exactly as if the app had called the setter. + /// + internal bool TrySetHookValueForDiagnostics(int index, object? value) + { + if ((uint)index >= (uint)_hooks.Count) return false; + if (!_hooks[index].TrySetDiagnosticValue(value, out bool changed)) return false; + if (changed) _requestRerender?.Invoke(); + return true; + } + + /// + /// Requests a re-render of the owning component through its own rerender + /// callback. Returns false when the context has never rendered. + /// + internal bool RequestRerenderForDiagnostics() + { + var rerender = _requestRerender; + if (rerender is null) return false; + rerender(); + return true; + } + internal abstract class HookState { // Q3 (spec 049 §3.6): set true by MigrateHooksForHotReload when this @@ -2697,10 +2707,108 @@ internal abstract class HookState // pass; surfaced by SnapshotHooks -> reactor.state. Reset to false at // the start of every hot-reload pass so it reflects only that pass. public bool Migrated; + + // Diagnostics read/write surface (ReactorDiagnostics.Components). Virtual + // dispatch on the cell keeps the generic T in scope, so inspecting or + // writing a hook needs no reflection and stays trim/AOT-safe. Never + // touched by the render path. + internal virtual HookKind DiagnosticKind => HookKind.Unknown; + internal virtual Type? DiagnosticValueType => null; + internal virtual object? DiagnosticValue => null; + // The cell's stored value as-is (a ref cell's Ref box rather than its Current), for SnapshotHooks. + internal virtual object? DiagnosticRawValue => DiagnosticValue; + internal virtual Type? DiagnosticRawValueType => DiagnosticValueType; + internal virtual bool DiagnosticCanSet => false; + + /// + /// Writes into the cell when it is assignable to the + /// cell's value type. reports whether the stored + /// value differs under — the same + /// gate the hook's own setter uses before requesting a re-render. + /// + internal virtual bool TrySetDiagnosticValue(object? value, out bool changed) + { + changed = false; + return false; + } + + private protected static bool TryUnbox(object? value, out TValue result) + { + if (value is TValue typed) + { + result = typed; + return true; + } + // `null` is assignable to reference types and Nullable, which the + // `is` pattern above rejects. + if (value is null && default(TValue) is null) + { + result = default!; + return true; + } + result = default!; + return false; + } } private class ValueHookState : HookState { + private static bool HoldsRef => typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Ref<>); + + // Keyed on origin, not only on T: UseRef never sets SetterKind, while UseState always + // does, so a UseState(new Ref()) cell stays an ordinary, settable state hook. + internal override HookKind DiagnosticKind => + SetterKind is SetterKindReducer or SetterKindDispatch + ? HookKind.Reducer + : SetterKind == 0 && HoldsRef + ? HookKind.Ref + : HookKind.State; + // A ref cell reports what it points at (Ref.Current, typed T), not the Ref box. + internal override Type? DiagnosticValueType => DiagnosticKind == HookKind.Ref && Value is IRefValue r ? r.ValueType : typeof(T); + // SnapshotHooks keeps reporting the stored value as-is (the Ref box for a ref). + internal override Type? DiagnosticRawValueType => typeof(T); + internal override object? DiagnosticRawValue + { + get + { + if (!ThreadSafe) return Value; + lock (Lock!) return Value; + } + } + internal override object? DiagnosticValue + { + get + { + object? v; + if (!ThreadSafe) v = Value; + else lock (Lock!) v = Value; + return DiagnosticKind == HookKind.Ref && v is IRefValue r ? r.CurrentBoxed : v; + } + } + // A Ref is mutated through Ref.Current and never schedules a render, + // so replacing the cell's Ref instance would silently detach the author's + // captured reference. Only state/reducer cells are writable. + internal override bool DiagnosticCanSet => DiagnosticKind != HookKind.Ref; + internal override bool TrySetDiagnosticValue(object? value, out bool changed) + { + changed = false; + if (!DiagnosticCanSet || !TryUnbox(value, out var typed)) return false; + if (ThreadSafe) + { + lock (Lock!) + { + changed = !EqualityComparer.Default.Equals(Value, typed); + if (changed) Value = typed; + } + } + else + { + changed = !EqualityComparer.Default.Equals(Value, typed); + if (changed) Value = typed; + } + return true; + } + public T Value; public readonly bool ThreadSafe; // Issue #659 (#42): only allocate the lock when threadSafe was requested. @@ -2732,6 +2840,7 @@ public ValueHookState(T value, bool threadSafe = false) private class EffectHookState : HookState { + internal override HookKind DiagnosticKind => HookKind.Effect; public object[]? Dependencies; public Action? Effect; public Func? EffectWithCleanup; @@ -2742,18 +2851,25 @@ private class EffectHookState : HookState private class MemoHookState : HookState { + internal override HookKind DiagnosticKind => HookKind.Memo; + internal override Type? DiagnosticValueType => typeof(T); + internal override object? DiagnosticValue => Value; public T Value = default!; public object[]? Dependencies; } internal class ContextHookState : HookState { + internal override HookKind DiagnosticKind => HookKind.Context; + internal override Type? DiagnosticValueType => Context?.ValueType; + internal override object? DiagnosticValue => LastValue; public ContextBase Context = default!; public object? LastValue; } internal class NavigationLifecycleHookState : HookState { + internal override HookKind DiagnosticKind => HookKind.NavigationLifecycle; public Action? OnNavigatingTo; public Action? OnNavigatedTo; public Action? OnNavigatingFrom; @@ -2773,6 +2889,18 @@ internal abstract class PersistedHookStateBase : HookState private class PersistedHookState : PersistedHookStateBase { + internal override HookKind DiagnosticKind => HookKind.Persisted; + internal override Type? DiagnosticValueType => typeof(T); + internal override object? DiagnosticValue => Value; + internal override bool DiagnosticCanSet => true; + internal override bool TrySetDiagnosticValue(object? value, out bool changed) + { + changed = false; + if (!TryUnbox(value, out var typed)) return false; + changed = !EqualityComparer.Default.Equals(Value, typed); + if (changed) Value = typed; + return true; + } public T Value; // Issue #659 (#53): ref-stable setter cached on first render. public Action? CachedSetter; @@ -2822,8 +2950,18 @@ public override void SaveToCache() /// /// A mutable reference that persists across renders (like React's useRef). /// -public class Ref +public class Ref : IRefValue { public T Current { get; set; } public Ref(T initial) => Current = initial; + + object? IRefValue.CurrentBoxed => Current; + Type IRefValue.ValueType => typeof(T); +} + +/// Non-generic read of a for diagnostics (no reflection). +internal interface IRefValue +{ + object? CurrentBoxed { get; } + Type ValueType { get; } } diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index f27f02636..a1cfb53da 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -90,6 +90,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE } SyncContentDialogProps(dialog, n); + TagDialogForDiagnostics(dialog, n); // Reconcile the side-mounted content in place so transient state inside // the dialog (focus, scroll, caret) survives the owner's re-renders. @@ -203,6 +204,42 @@ void OnLoaded(object sender, RoutedEventArgs _) internal static bool ShouldStartDeferredDialog(Element? tag, bool alreadyShowing) => !alreadyShowing && tag is ContentDialogElement { IsOpen: true }; + /// + /// Points the live back at its element so an inspector + /// that picks the dialog's chrome (title, primary/secondary/close buttons) can attribute + /// it to the ContentDialog(...) call site: it walks up to the dialog and reads + /// / + /// . + /// + /// The placeholder already carries the tag, but it is collapsed and lives in the + /// owner's tree, while the dialog is shown in its own popup root, so no ancestor walk + /// from a dialog button can reach it. Tagging the dialog is the only bridge. + /// + /// Nothing in Reactor reads this tag back (callbacks resolve through the + /// placeholder), so it is only written when there is something to attribute — see + /// — and production pays nothing. + /// + private static void TagDialogForDiagnostics(WinUI.ContentDialog dialog, ContentDialogElement element) + // Passing null when tagging is off retires a tag an earlier render wrote (the flag + // was flipped off while the dialog stayed open), and is a no-op on an untagged dialog. + => Reconciler.SetElementTag(dialog, ShouldTagDialog(element) ? element : null); + + /// + /// Tag when the element carries a call site (it was stamped, or hand-stamped) or when + /// source mapping is on (so an inspector can still read the element record). + /// + internal static bool ShouldTagDialog(ContentDialogElement element) + => element.CallSite is not null || Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled; + + /// + /// True when 's tag only attributes it to its element: a side-mounted + /// ContentDialog tagged for source attribution. The element's modifiers were applied to + /// the collapsed placeholder, not to the dialog, so diagnostics must not read them as the + /// dialog's own. + /// + internal static bool IsAttributionOnlyTag(UIElement control, Element tag) + => control is WinUI.ContentDialog && tag is ContentDialogElement; + private static async void ShowContentDialogCore(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, XamlRoot? xamlRoot, Action requestRerender) { var dialog = new WinUI.ContentDialog @@ -216,6 +253,7 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi if (cdEl.CloseButtonText is not null) dialog.CloseButtonText = cdEl.CloseButtonText; dialog.Content = reconciler.Mount(cdEl.Content, requestRerender); if (xamlRoot is not null) dialog.XamlRoot = xamlRoot; + TagDialogForDiagnostics(dialog, cdEl); // Resolve callbacks through the anchor's live Tag the way Flyout/Popup do, // rather than capturing the mount-time element: the dialog re-renders // while open so those closures go stale, and clearing the tag is how @@ -241,6 +279,9 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi // the tracking entry and leak the content subtree mounted above. ownsClose = s_liveDialogs.TryGetValue(anchor, out var tracked) && ReferenceEquals(tracked, dialog); if (ownsClose) s_liveDialogs.Remove(anchor); + // A caller can keep the dialog through .Set(...); a closed dialog must not keep + // resolving to (and keeping alive) its element. No-op when it was never tagged. + Reconciler.SetElementTag(dialog, null); // Unmount the content: it was mounted at open time and nothing else // tears it down, so every open/close cycle would otherwise leak its // component cleanups. Already null when teardown took ownership. diff --git a/src/Reactor/Core/V1Protocol/ReactorBindingT.cs b/src/Reactor/Core/V1Protocol/ReactorBindingT.cs index 8a6ef2e43..60c47921b 100644 --- a/src/Reactor/Core/V1Protocol/ReactorBindingT.cs +++ b/src/Reactor/Core/V1Protocol/ReactorBindingT.cs @@ -25,7 +25,7 @@ namespace Microsoft.UI.Reactor.Core.V1Protocol; /// public readonly struct ReactorBinding where TElement : Element { - private const int ReferenceSlotBase = 100_000; + private const int ReferenceSlotBase = ReferenceSlots.BindingBase; [ThreadStatic] private static int s_referenceSlotCount; diff --git a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs index d19077d8f..bbbce4cfe 100644 --- a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs +++ b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs @@ -24,6 +24,12 @@ internal sealed class ReferenceEdge internal sealed class ReferenceListEdge { public readonly List Cells = new(); + /// + /// The list as last authored, in order and with repeats, minus null entries (which carry no + /// reference; callers drop them before wiring). is deduplicated + /// subscription bookkeeping; diagnostics project this instead. + /// + public IReadOnlyList? Authored; public Action? Handler; public Action? Recompute; /// @@ -44,6 +50,40 @@ internal static class ReferenceSlots public const int ModifierRef_XYFocusLeft = 200_012; public const int ModifierRef_XYFocusRight = 200_013; public const int ModifierRef_ToolTipPlacementTarget = 200_020; + + /// + /// Diagnostics-only pseudo-slot for the AutomationId form of .LabeledBy("id"), + /// which resolves through a visual-tree search rather than a reference edge and so has + /// no bag entry. Never used as a key. + /// + public const int ModifierRef_LabeledById = 200_030; + + /// First slot of the imperative ReactorBinding.Reference bridge. + public const int BindingBase = 100_000; + + /// First slot of the named modifier-level edges above. + public const int ModifierBase = 200_000; + + /// + /// Human-readable name for a slot. Modifier slots are named; descriptor and binding + /// slots are allocated in declaration order and carry no author-visible name. Shared by + /// ReactorDiagnostics.GetReferenceEdges and the devtools reference overlay. + /// + public static string Label(int slot) => slot switch + { + ModifierRef_LabeledBy or ModifierRef_LabeledById => "LabeledBy", + ModifierRef_DescribedBy => "DescribedBy", + ModifierRef_FlowsTo => "FlowsTo", + ModifierRef_FlowsFrom => "FlowsFrom", + ModifierRef_XYFocusUp => "XYFocusUp", + ModifierRef_XYFocusDown => "XYFocusDown", + ModifierRef_XYFocusLeft => "XYFocusLeft", + ModifierRef_XYFocusRight => "XYFocusRight", + ModifierRef_ToolTipPlacementTarget => "ToolTipPlacementTarget", + >= ModifierBase => $"modifier#{slot}", + >= BindingBase => $"binding#{slot - BindingBase}", + _ => $"reference#{slot}", + }; } } diff --git a/src/Reactor/Diagnostics/ReactorSourceMap.cs b/src/Reactor/Diagnostics/ReactorSourceMap.cs index 258002d45..23d1428b9 100644 --- a/src/Reactor/Diagnostics/ReactorSourceMap.cs +++ b/src/Reactor/Diagnostics/ReactorSourceMap.cs @@ -165,6 +165,26 @@ public static bool Enabled /// private const int MaxDecoratorDepth = 64; + /// + /// The decorator chain from (outermost) to its innermost target, + /// for diagnostics that must see every level: a decorator mounts its target and then applies + /// its own modifiers to the same control, so outer levels win. Stops at a repeated element + /// (a cyclic resolver) or after steps. On-demand only — it + /// allocates, unlike . + /// + internal static IReadOnlyList DecoratorChain(Element element) + { + var chain = new List { element }; + for (var depth = 0; depth < MaxDecoratorDepth; depth++) + { + if (DecoratorTarget(chain[^1]) is not { } next) break; + foreach (var seen in chain) + if (ReferenceEquals(seen, next)) return chain; + chain.Add(next); + } + return chain; + } + /// /// The wrapped element for a target-wrapping decorator, or null for anything else. /// diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index e1c728427..6a0d75914 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -48,6 +48,10 @@ public sealed class ReactorHost : IDisposable, Core.Diagnostics.IReactorDiagnost private Element? _currentTree; private UIElement? _currentControl; + // The root that produced _currentControl (diagnostics); see RenderedRoot. + private RenderedRoot _renderedRoot; + // An error fallback (not the root's output) is displayed; a null render keeps it shown. + private bool _showingErrorContent; private int _renderPending; // 0 or 1 — Interlocked for thread-safe access private volatile bool _isRendering; // only touched on UI thread private volatile bool _needsRerender; // only touched on UI thread @@ -189,6 +193,57 @@ public ReactorWindow? OwningWindow internal UIElement? CurrentControl => _currentControl; + /// + /// for this host. The root anchor is + /// the rendered root control, or — while this host's content is the one installed there — + /// the element it installed it into (the border or the window + /// content) and the dev-overlay wrapper it puts around the root while an overlay is on. + /// Several hosts can share one container over time (a replaced, undisposed host keeps + /// its stale reference to it), so the container only counts for the host whose content + /// is actually in it — or, while it is empty (an Empty() root), for the host that + /// last rendered into it. + /// + private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) + { + if (_disposed) return null; + // Null when the root rendered Empty(); see the ownership rule below. + var control = _currentControl; + + // Resolve against where the displayed root was published, not the requested ContentTarget: + // retargeting takes effect only when the next render writes the new container, and until + // then the previous one is still the root's anchor. A claimed Border is that destination; + // with no claim the root went to the window content (a window write releases any claim). + var published = _claimedContentTarget as WinUI.Border; + if (published is not null && !OwnsContentTarget(published)) published = null; // taken over by another host + UIElement? container = published + ?? (_claimedContentTarget is null && !_windowClosed ? _window.Content as UIElement : null); + UIElement? installed = published is not null ? published.Child : container; + var wrapper = _overlayWiring?.WrapperRoot; + // Content identity proves ownership when there is content. An empty container proves + // nothing (any empty-root host sharing it would match), so then it must be the container + // this host published into. + bool ours = installed is not null + ? ReferenceEquals(installed, control) || (wrapper is not null && ReferenceEquals(installed, wrapper)) + : control is null && published is not null; + + bool isAnchor = (control is not null && ReferenceEquals(element, control)) + || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); + if (!isAnchor) return null; + + // The root that produced the displayed control, never the requested one: Mount swaps the + // requested root before its queued render. Empty once that root is retired (RetireRoot), + // so a control still on screen from a retired root describes nothing. + var rendered = _renderedRoot; + if (rendered.IsEmpty) return null; + return new RootComponentSource( + rendered.Component, + rendered.FuncContext, + rendered.RenderFunc, + control, + _currentTree, + () => !_disposed && _renderedRoot.SameRootAs(rendered)); + } + /// /// Optional: when set, Reactor renders into this Border instead of Window.Content. /// Useful for embedding Reactor content in a pre-existing layout (e.g., a test harness @@ -228,10 +283,12 @@ public ReactorHost(Window window, ILogger? logger = null) // Microsoft.Extensions.Logging call paths. _logger = logger ?? ReactorApp.AppLogger; _reconciler = new Reconciler(_logger); + _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _reconciler.RenderErrorHandlerProvider = () => EffectiveRenderErrorHandler; _window = window; _backdropApplier = new BackdropApplier(window); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _reconciler.OwningDispatcher = _dispatcherQueue; // Off-thread rerenders marshal via ReactorApp.UIDispatcher (captured // in OnLaunched). For embedded ReactorHostControl scenarios where // there's no Reactor.Run, fall back to seeding UIDispatcher with this @@ -309,6 +366,8 @@ void Core.Diagnostics.IReactorDiagnosticHost.TagComponentBoundaries() _dispatcherQueue.TryEnqueue(() => { if (!_disposed) _reconciler.TagComponentBoundaries(); }); } + Reconciler? Core.Diagnostics.IReactorDiagnosticHost.DiagnosticReconciler => _disposed ? null : _reconciler; + Core.Diagnostics.ReactorHostInfo? Core.Diagnostics.IReactorDiagnosticHost.CaptureDiagnosticInfo() { if (_disposed) return null; @@ -529,6 +588,9 @@ private void RetireDeferredRoots() foreach (var (component, funcContext) in deferred) { // Every queued root is retired; only the first failure is kept. + // The retired root may be the one diagnostics still describes (it called Mount mid-pass). + if (_renderedRoot.SameRootAs(new RenderedRoot(component, funcContext, null))) + _renderedRoot = default; var failure = RetireContexts(component, funcContext); first ??= failure; } @@ -578,6 +640,9 @@ private void RetireDeferredRoots() (_deferredRetirements ??= new()).Add((outgoingComponent, outgoingFuncContext)); return null; } + // Retired now: its hooks are dropped, so diagnostics no longer describes or writes the + // root whose control may still be on screen until the replacement renders. + _renderedRoot = default; return RetireContexts(outgoingComponent, outgoingFuncContext); } @@ -635,6 +700,47 @@ internal void Mount(Func renderFunc, SourceLocation? mou retireFailure?.Throw(); } + // Diagnostics ownership of a shared ContentTarget: the host that last rendered into it + // (wrote its content, or published an Empty() root while targeting it) — so ownership and + // the published rendered root always change together. Only consulted when the container is + // empty, where content identity cannot say whose it is. Not written on an ordinary + // re-render that changes nothing. The owner is held weakly and released on Dispose, so the + // table never keeps a host (or its window and reconciler) alive. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> s_contentTargetOwner = new(); + + private void ClaimContentTarget() + { + if (ContentTarget is not { } target) return; + // Already the owner: nothing to update (keeps repeat renders allocation-free). + if (ReferenceEquals(_claimedContentTarget, target) && OwnsContentTarget(target)) return; + // Retargeted: drop the claim on the previous container so it cannot resolve to a root + // this host has since rendered elsewhere. + if (_claimedContentTarget is { } previous && !ReferenceEquals(previous, target) && OwnsContentTarget(previous)) + s_contentTargetOwner.Remove(previous); + s_contentTargetOwner.AddOrUpdate(target, new WeakReference(this)); + _claimedContentTarget = target; + } + + // The container this host last claimed (not necessarily the current ContentTarget). + private UIElement? _claimedContentTarget; + + private void ReleaseContentTarget() + { + if (_claimedContentTarget is { } target && OwnsContentTarget(target)) + s_contentTargetOwner.Remove(target); + _claimedContentTarget = null; + } + + private bool OwnsContentTarget(UIElement target) + => ReferenceEquals(_claimedContentTarget, target) + && s_contentTargetOwner.TryGetValue(target, out var owner) + && owner.TryGetTarget(out var host) && ReferenceEquals(host, this); + + // Test-only accessor (InternalsVisibleTo Reactor.AppTests.Host): whether any live host owns + // the container in the diagnostics ownership table. + internal static bool HasContentTargetOwnerForTest(UIElement target) + => s_contentTargetOwner.TryGetValue(target, out var owner) && owner.TryGetTarget(out _); + // Test seam: runs right after RequestRender reads _isRendering, so a selftest can finish the // in-flight render pass inside that window. Null outside the test. internal static Action? RequestRenderAfterRenderingCheckForTest { get; set; } @@ -859,6 +965,9 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) // clears the snapshot when the pass ends). _renderingRoot = _rootComponent; _renderingRootDiagnostics = _rootDiagnostics; + // Same snapshot for component inspection: Render() can Mount() a replacement, and the + // tree this pass commits is still the original root's. + var passRoot = new RenderedRoot(_rootComponent, _funcContext, _rootRenderFunc); Element? newTree = null; @@ -970,6 +1079,16 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) TraceRootRendered(hotReloadRender, treeBuildMs); if (_releaseReplacedTreeOnNullRender) ReleaseReplacedTree(); + // The root rendered (to nothing) and its hooks are live: publish it like the + // non-null path does, so diagnostics can reach it. Claimed only into an empty + // container, as for an Empty() render. + // Not while it keeps an error fallback on screen: that content is not the root's. + if (!passRoot.IsEmpty && !_showingErrorContent) + { + _renderedRoot = passRoot; + if (_currentControl is null && ContentTarget is { Child: null } nullTarget && !OwnsContentTarget(nullTarget)) + ClaimContentTarget(); + } // Commit the render like any other: the root's effects still run, and a // failure among them is routed as an effect failure by the outer catch. failurePhase = RenderErrorSource.Effects; @@ -1036,9 +1155,15 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) if (anyOverlayOn) contentToSet = _overlayWiring!.SetContentViaWrapper(newControl); if (ContentTarget is not null) + { ContentTarget.Child = contentToSet; + ClaimContentTarget(); + } else + { _window.Content = contentToSet; + ReleaseContentTarget(); + } AttachThemeListener(newControl); } else if (anyOverlayOn && _overlayWiring!.WrapperRoot is null) @@ -1052,9 +1177,15 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _window.Content = null; var wrapper = _overlayWiring.SetContentViaWrapper(newControl); if (ContentTarget is not null) + { ContentTarget.Child = wrapper; + ClaimContentTarget(); + } else + { _window.Content = wrapper; + ReleaseContentTarget(); + } Debug.WriteLine($"[Reactor.Overlay] wrapper installed mid-session; content={newControl?.GetType().Name ?? "null"}"); } else if (!anyOverlayOn && _overlayWiring?.WrapperRoot is not null) @@ -1066,15 +1197,28 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) // window. _overlayWiring.DetachContent(); if (ContentTarget is not null) + { ContentTarget.Child = newControl; + ClaimContentTarget(); + } else + { _window.Content = newControl; + ReleaseContentTarget(); + } _overlayWiring.Dispose(); _overlayWiring = null; } _currentControl = newControl; _currentTree = newTree; + _renderedRoot = passRoot; + _showingErrorContent = false; + // An Empty() render may write nothing (the container was already empty), yet the + // container now shows this root: claim it alongside the published root. Only an empty + // container: an occupied one still shows another host's content, which keeps it. + if (newControl is null && ContentTarget is { Child: null } emptyTarget && !OwnsContentTarget(emptyTarget)) + ClaimContentTarget(); // A root replaced during this pass (it called Mount) still committed its own tree; // that tree is the replacement's to release if its first render produces nothing. _releaseReplacedTreeOnNullRender = RootReplacedDuringPass; @@ -1381,6 +1525,8 @@ public void Dispose() _funcContext = null; _currentTree = null; _currentControl = null; + _renderedRoot = default; + ReleaseContentTarget(); try { _overlayWiring?.Dispose(); } catch { /* best effort */ } _overlayWiring = null; @@ -1458,13 +1604,18 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep else if (ContentTarget is not null) { ContentTarget.Child = errorPanel; + ClaimContentTarget(); } else { _window.Content = errorPanel; + ReleaseContentTarget(); } _currentControl = errorPanel; _currentTree = errorTree; + // A fallback is shown, not the root's output, so no root is described from it. + _renderedRoot = default; + _showingErrorContent = errorPanel is not null || errorTree is not null; // When the handler's outcome replaced (and released) the tree, the theme listener // moves to the new content like any content swap, or is detached when there is none, // so it neither pins the released root nor misses a ThemeRef-bound app fallback. The diff --git a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs new file mode 100644 index 000000000..b434fdd14 --- /dev/null +++ b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs @@ -0,0 +1,39 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Xaml; + +namespace Microsoft.UI.Reactor.Hosting; + +public sealed partial class ReactorHostControl +{ + /// + /// for this control. The root anchor + /// is the control itself, the rendered root control, or its current content while that + /// content is still Reactor's (the rendered root, or the dev-overlay wrapper around it) — + /// Content is public, so an element a consumer swapped in is never attributed. + /// + private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) + { + if (_disposed) return null; + // Null when the root rendered Empty(): the control itself still anchors it, and the + // rendered-root check below rejects pre-render, error and disposed states. + var control = _currentControl; + var content = Content; + var wrapper = _overlayWiring?.WrapperRoot; + bool contentIsOurs = ReferenceEquals(content, control) || (wrapper is not null && ReferenceEquals(content, wrapper)); + bool isAnchor = ReferenceEquals(element, this) + || ReferenceEquals(element, control) + || (contentIsOurs && ReferenceEquals(element, content)); + if (!isAnchor) return null; + + // The root that produced the displayed control, not the requested one (see RenderedRoot). + var rendered = _renderedRoot; + if (rendered.IsEmpty) return null; + return new RootComponentSource( + rendered.Component, + rendered.FuncContext, + rendered.RenderFunc, + control, + _currentTree, + () => !_disposed && _renderedRoot.SameRootAs(rendered)); + } +} diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index af5921137..c25f8b2f9 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -74,6 +74,10 @@ public sealed partial class ReactorHostControl : ContentControl, IDisposable, Co private Element? _currentTree; private UIElement? _currentControl; + // The root that produced _currentControl (diagnostics); see RenderedRoot. + private RenderedRoot _renderedRoot; + // An error fallback (not the root's output) is displayed; a null render keeps it shown. + private bool _showingErrorContent; private int _renderPending; // 0 or 1 — Interlocked for thread-safe access private volatile bool _isRendering; // only touched on UI thread private volatile bool _needsRerender; // only touched on UI thread @@ -230,8 +234,10 @@ public ReactorHostControl(Component? component = null, ILogger? logger = null) // already-constructed controls. _logger = logger ?? ReactorApp.AppLogger; _reconciler = new Reconciler(_logger); + _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _reconciler.RenderErrorHandlerProvider = () => EffectiveRenderErrorHandler; _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _reconciler.OwningDispatcher = _dispatcherQueue; // A standalone ReactorHostControl has no ReactorApp bootstrap, so nothing else // sets ReactorApp.UIDispatcher. Cross-thread setState — including the re-render // that UseValidationContext schedules when a background async validator raises @@ -266,6 +272,8 @@ void Core.Diagnostics.IReactorDiagnosticHost.TagComponentBoundaries() _dispatcherQueue.TryEnqueue(() => { if (!_disposed) _reconciler.TagComponentBoundaries(); }); } + Reconciler? Core.Diagnostics.IReactorDiagnosticHost.DiagnosticReconciler => _disposed ? null : _reconciler; + Core.Diagnostics.ReactorHostInfo? Core.Diagnostics.IReactorDiagnosticHost.CaptureDiagnosticInfo() { if (_disposed) return null; @@ -402,6 +410,9 @@ private void RetireDeferredRoots() foreach (var (component, funcContext) in deferred) { // Every queued root is retired; only the first failure is kept. + // The retired root may be the one diagnostics still describes (it called Mount mid-pass). + if (_renderedRoot.SameRootAs(new RenderedRoot(component, funcContext, null))) + _renderedRoot = default; var failure = RetireContexts(component, funcContext); first ??= failure; } @@ -451,6 +462,9 @@ private void RetireDeferredRoots() (_deferredRetirements ??= new()).Add((outgoingComponent, outgoingFuncContext)); return null; } + // Retired now: its hooks are dropped, so diagnostics no longer describes or writes the + // root whose control may still be on screen until the replacement renders. + _renderedRoot = default; return RetireContexts(outgoingComponent, outgoingFuncContext); } @@ -863,6 +877,9 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) // clears the snapshot when the pass ends). _renderingRoot = _rootComponent; _renderingRootDiagnostics = _rootDiagnostics; + // Same snapshot for component inspection: Render() can Mount() a replacement, and the + // tree this pass commits is still the original root's. + var passRoot = new RenderedRoot(_rootComponent, _funcContext, _rootRenderFunc); Element? newTree = null; @@ -960,6 +977,11 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) TraceRootRendered(hotReloadRender, treeBuildMs); if (_releaseReplacedTreeOnNullRender) ReleaseReplacedTree(); + // The root rendered (to nothing) and its hooks are live: publish it like the + // non-null path does, so diagnostics can reach it through this control. + // Not while it keeps an error fallback on screen: that content is not the root's. + if (!passRoot.IsEmpty && !_showingErrorContent) + _renderedRoot = passRoot; // Commit the render like any other: the root's effects still run, and a // failure among them is routed as an effect failure by the outer catch. failurePhase = RenderErrorSource.Effects; @@ -1041,6 +1063,8 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _currentControl = newControl; _currentTree = newTree; + _renderedRoot = passRoot; + _showingErrorContent = false; // A root replaced during this pass (it called Mount) still committed its own tree; // that tree is the replacement's to release if its first render produces nothing. _releaseReplacedTreeOnNullRender = RootReplacedDuringPass; @@ -1270,6 +1294,9 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep } _currentControl = errorPanel; _currentTree = errorTree; + // A fallback is shown, not the root's output, so no root is described from it. + _renderedRoot = default; + _showingErrorContent = errorPanel is not null || errorTree is not null; // See ReactorHost.SetErrorContent: when the handler's outcome replaced the tree, the // theme listener follows the new content, or is detached when there is none. if (replacesTree) @@ -1339,6 +1366,7 @@ public void Dispose() _funcContext = null; _currentTree = null; _currentControl = null; + _renderedRoot = default; try { _overlayWiring?.Dispose(); } catch { /* best effort */ } _overlayWiring = null; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs new file mode 100644 index 000000000..6a8024f0f --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -0,0 +1,634 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Core.Diagnostics; +using Microsoft.UI.Reactor.Diagnostics; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// The live half of component inspection: describing the +/// component at a real wrapper / host root , editing its state from text, +/// re-rendering it, and reading the modifier-applied properties of a tagged control. Everything +/// these calls delegate to is pinned headless in ReactorDiagnosticsComponentsTests. +/// +internal static class ComponentInspectionFixtures +{ + private const string SkipReason = + "assembly built without REACTOR_SOURCEMAP (Release) - a plain leaf is not tagged, so it has no element to read modifiers from"; + + private sealed record CounterProps(int Step, string AdminPassword); + + private static Exception? Record(Action action) + { + try { action(); return null; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return ex; + } + } + + private sealed class Counter : Component + { + public override Element Render() + { + var (n, _) = UseState(0); + return TextBlock($"inspect-count:{n}").Width(120).AutomationName("inspect-counter"); + } + } + + internal class Inspect(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var previous = ReactorSourceMap.Enabled; + ReactorSourceMap.Enabled = true; + try + { + var host = H.CreateHost(); + host.Mount(ctx => VStack( + Component(new CounterProps(2, "hunter2")), + Memo(c => + { + var (s, _) = c.UseState("m0"); + return TextBlock($"inspect-memo:{s}"); + }))); + await Harness.Render(); + + var leaf = H.FindControl(t => t.Text == "inspect-count:0"); + var memoLeaf = H.FindControl(t => t.Text == "inspect-memo:m0"); + H.Check("CompInspect_Mounted", leaf is not null && memoLeaf is not null); + if (leaf is null || memoLeaf is null) return; + + // ── Class component, described from its wrapper ── + var wrapper = VisualTreeHelper.GetParent(leaf) as UIElement; + H.Check("CompInspect_WrapperFound", wrapper is Border); + if (wrapper is null) return; + + H.Check("CompInspect_LeafIsNotAComponent", ReactorDiagnostics.DescribeComponent(leaf) is null); + + // Off its UI thread, a read fails even with no process-wide dispatcher captured + // (an embedded ReactorHostControl without ReactorApp.Run): the element's own + // dispatcher decides. + var savedDispatcher = ReactorApp.UIDispatcher; + Exception? offThread = null; + try + { + ReactorApp.UIDispatcher = null; + var reader = new global::System.Threading.Thread(() => + offThread = Record(() => ReactorDiagnostics.DescribeComponent(wrapper))); + reader.Start(); + reader.Join(); + } + finally { ReactorApp.UIDispatcher = savedDispatcher; } + H.Check("CompInspect_OffThreadReadThrows", offThread is InvalidOperationException, + offThread?.GetType().Name ?? "no exception"); + + // Only hosted reconcilers are inspected: lookups walk the host registry behind + // GetHosts(), so a reconciler driven directly is never read. + using (var direct = new Reconciler()) + { + var directWrapper = direct.Mount(Component(new CounterProps(1, "x")), () => { }); + H.Check("CompInspect_UnhostedReconcilerNotInspected", + directWrapper is not null && ReactorDiagnostics.DescribeComponent(directWrapper) is null); + } + // A hosted reconciler with no dispatcher recorded is never inspected from any thread. + var recorded = host.Reconciler.OwningDispatcher; + host.Reconciler.OwningDispatcher = null; + H.Check("CompInspect_UncapturedReconcilerNotInspected", ReactorDiagnostics.DescribeComponent(wrapper) is null); + host.Reconciler.OwningDispatcher = recorded; + var counter = ReactorDiagnostics.DescribeComponent(wrapper); + H.Check("CompInspect_ClassDescribed", + counter is { Name: "Counter", Kind: "class", IsRoot: false }); + // NativeAOT/trimming may strip the props record's metadata; Props then degrades to one + // "(members unavailable)" row. Values are shown as they are (no redaction). + var fullProps = counter is not null + && counter.Props.Count == 2 + && counter.Props[0] is { Name: "Step", Type: "int", Value: "2" } + && counter.Props[1] is { Name: "AdminPassword", Value: "\"hunter2\"" }; + var opaqueProps = counter is { Props: [{ Name: "Props", Value: "CounterProps (members unavailable)" }] }; + H.Check("CompInspect_ClassProps", + counter is not null + && (fullProps || (!global::System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported && opaqueProps))); + H.Check("CompInspect_ClassState", + counter is not null && counter.State is [{ Index: 0, Kind: "state", Type: "int", Value: "0", Editable: true }]); + + // ── State edit from text re-renders exactly like the setter ── + H.Check("CompInspect_SetStateAccepted", ReactorDiagnostics.TrySetState(wrapper, 0, "5", out var setError) && setError is null); + await Harness.Render(); + H.Check("CompInspect_SetStateRerendered", H.FindControl(t => t.Text == "inspect-count:5") is not null); + H.Check("CompInspect_SetStateRefusesBadText", + !ReactorDiagnostics.TrySetState(wrapper, 0, "five", out var badError) && badError == "'five' is not a valid int"); + + // The rerender a write triggers runs on the host's own UI thread, not on the + // process-wide ReactorApp.UIDispatcher (another window's, for hosts on a second UI + // thread). Point the process-wide one at a shut-down foreign queue: a rerender + // marshalled there would be dropped and the text would never update. + var foreignUi = Microsoft.UI.Dispatching.DispatcherQueueController.CreateOnDedicatedThread(); + await foreignUi.ShutdownQueueAsync(); + var processWide = ReactorApp.UIDispatcher; + try + { + ReactorApp.UIDispatcher = foreignUi.DispatcherQueue; + H.Check("CompInspect_SetStateWithForeignProcessDispatcher", ReactorDiagnostics.TrySetState(wrapper, 0, "6", out _)); + } + finally { ReactorApp.UIDispatcher = processWide; } + await Harness.WaitFor(() => H.FindControl(t => t.Text == "inspect-count:6") is not null, maxPasses: 16, perPassMs: 10); + H.Check("CompInspect_RerenderUsesTheOwningDispatcher", H.FindControl(t => t.Text == "inspect-count:6") is not null); + ReactorDiagnostics.TrySetState(wrapper, 0, "5", out _); + await Harness.WaitFor(() => H.FindControl(t => t.Text == "inspect-count:5") is not null, maxPasses: 16, perPassMs: 10); + + // ── Memo component ── + var memoWrapper = VisualTreeHelper.GetParent(memoLeaf) as UIElement; + var memo = memoWrapper is null ? null : ReactorDiagnostics.DescribeComponent(memoWrapper); + H.Check("CompInspect_MemoDescribed", + memo is { Kind: "memo", State: [{ Kind: "state", Value: "\"m0\"" }] } && memo.Name.StartsWith("Memo in ", StringComparison.Ordinal)); + + // ── Host root (a render function), and an explicit re-render ── + var rootControl = host.CurrentControl; + var root = rootControl is null ? null : ReactorDiagnostics.DescribeComponent(rootControl); + H.Check("CompInspect_RootDescribed", root is { IsRoot: true, Kind: "function" } && root.Name.StartsWith("render in ", StringComparison.Ordinal)); + H.Check("CompInspect_RootRerender", rootControl is not null && ReactorDiagnostics.Rerender(rootControl)); + await Harness.Render(); + + // ── Modifier-applied properties on a tagged leaf ── + var live = H.FindControl(t => t.Text == "inspect-count:5"); +#if REACTOR_SOURCEMAP + var applied = live is null ? null : ReactorDiagnostics.GetAppliedProperties(live); + H.Check("CompInspect_AppliedProperties", + applied is not null + && applied.Contains(new AppliedProperty("Width", "FrameworkElement.Width", "120")) + && applied.Contains(new AppliedProperty("AutomationName", "AutomationProperties.Name", "inspect-counter"))); + // The explicit name suppresses the caption-derived default… + H.Check("CompInspect_ExplicitNameHasNoDefault", + applied is not null && !applied.Any(p => p.Modifier == "DefaultAutomationName")); + // …while an unnamed captioned leaf reports the default Reactor wrote for it. + var memoApplied = ReactorDiagnostics.GetAppliedProperties(memoLeaf); + H.Check("CompInspect_DefaultAutomationNameReported", + memoApplied.Contains(new AppliedProperty("DefaultAutomationName", "AutomationProperties.Name", "inspect-memo:m0"))); +#else + _ = live; + H.Skip("CompInspect_AppliedProperties", SkipReason); + H.Skip("CompInspect_ExplicitNameHasNoDefault", SkipReason); + H.Skip("CompInspect_DefaultAutomationNameReported", SkipReason); +#endif + + // ── An unmounted component is no longer described or written ── + host.Mount(ctx => TextBlock("inspect-gone")); + await Harness.Render(); + H.Check("CompInspect_UnmountedNotDescribed", ReactorDiagnostics.DescribeComponent(wrapper) is null); + H.Check("CompInspect_UnmountedRefusesWrites", + !ReactorDiagnostics.TrySetState(wrapper, 0, "6", out var goneError) && goneError is not null + && !ReactorDiagnostics.Rerender(wrapper)); + } + finally + { + ReactorSourceMap.Enabled = previous; + } + } + } + + private sealed class RootCounter : Microsoft.UI.Reactor.Core.Component + { + public override Element Render() + { + var (n, _) = UseState(7); + return TextBlock($"root-anchor:{n}"); + } + } + + private sealed class SwitchedIn : Microsoft.UI.Reactor.Core.Component + { + public override Element Render() + { + var (s, _) = UseState("in"); + _ = s; + return TextBlock("switched-in"); + } + } + + // Mounts a replacement from inside its own first Render(). + private sealed class SwitchingRoot : Microsoft.UI.Reactor.Core.Component + { + public ReactorHost? Host; + public override Element Render() + { + var (s, _) = UseState("out"); + _ = s; + Host?.Mount(new SwitchedIn()); + Host = null; + return TextBlock("switching-out"); + } + } + + private sealed class ShellChild : Microsoft.UI.Reactor.Core.Component + { + public override Element Render() + { + var (s, _) = UseState("c"); + return TextBlock($"shell-child:{s}"); + } + } + + private sealed class ShellRoot : Microsoft.UI.Reactor.Core.Component + { + public override Element Render() + { + var (n, _) = UseState(1); + _ = n; + return Component(); + } + } + + /// + /// Every element a host root is reachable from resolves to the root component: the host's + /// rendered control, its ContentTarget, the dev-overlay wrapper the host installs in + /// that target while an overlay is on, and a ReactorHostControl and its content. When the + /// rendered control is also a child component's wrapper, the child wins there. + /// + internal class RootAnchors(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(new RootCounter()); + await Harness.Render(); + + var target = host.ContentTarget; + H.Check("RootAnchor_HostHasContentTarget", target is not null && host.CurrentControl is not null); + if (target is null || host.CurrentControl is null) return; + + static bool IsRoot(ComponentSnapshot? s) => + s is { IsRoot: true, Kind: "class", Name: "RootCounter", State: [{ Value: "7" }] }; + + H.Check("RootAnchor_RenderedControl", IsRoot(ReactorDiagnostics.DescribeComponent(host.CurrentControl))); + H.Check("RootAnchor_ContentTarget", IsRoot(ReactorDiagnostics.DescribeComponent(target))); + + // A reconciler owned by another UI thread is never read: with this host's dispatcher + // swapped for a dedicated thread's, its root no longer resolves from here. + var ownDispatcher = host.Reconciler.OwningDispatcher; + var foreignThread = Microsoft.UI.Dispatching.DispatcherQueueController.CreateOnDedicatedThread(); + try + { + host.Reconciler.OwningDispatcher = foreignThread.DispatcherQueue; + H.Check("RootAnchor_ForeignThreadReconcilerSkipped", ReactorDiagnostics.DescribeComponent(target) is null); + } + finally + { + host.Reconciler.OwningDispatcher = ownDispatcher; + await foreignThread.ShutdownQueueAsync(); + } + H.Check("RootAnchor_OwnThreadReconcilerRead", IsRoot(ReactorDiagnostics.DescribeComponent(target))); + + // With an overlay on, ContentTarget holds a wrapper around the rendered root. + var prevOverlay = ReactorFeatureFlags.HighlightReconcileChanges; + ReactorFeatureFlags.HighlightReconcileChanges = true; + try + { + H.Check("RootAnchor_RerenderForOverlay", ReactorDiagnostics.Rerender(target)); + await Harness.Render(); + var wrapper = target.Child; + H.Check("RootAnchor_OverlayWrapperInstalled", wrapper is not null && !ReferenceEquals(wrapper, host.CurrentControl)); + H.Check("RootAnchor_OverlayWrapper", wrapper is not null && IsRoot(ReactorDiagnostics.DescribeComponent(wrapper))); + } + finally + { + ReactorFeatureFlags.HighlightReconcileChanges = prevOverlay; + ReactorDiagnostics.Rerender(target); + await Harness.Render(); + } + + // A root that renders a component directly: its rendered control is the child's + // wrapper, which describes the child; the ContentTarget still reaches the root. + host.Mount(new ShellRoot()); + // Mount retires the previous root at once (its cleanups run, its hooks are dropped) + // but renders the new one later. In that gap the old control is still on screen, + // yet neither the retired root nor the not-yet-rendered one is described. + H.Check("RootAnchor_RemountGapDescribesNoRetiredRoot", ReactorDiagnostics.DescribeComponent(target) is null); + await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child:c") is not null, + maxPasses: 16, perPassMs: 10); + var overlap = host.CurrentControl; + H.Check("RootAnchor_OverlapRenderedControlIsChild", + overlap is not null && ReactorDiagnostics.DescribeComponent(overlap) is { IsRoot: false, Name: "ShellChild", State: [{ Value: "\"c\"" }] }); + H.Check("RootAnchor_OverlapContentTargetIsRoot", + ReactorDiagnostics.DescribeComponent(target) is { IsRoot: true, Name: "ShellRoot", State: [{ Value: "1" }] }); + H.Check("RootAnchor_OverlapEditsAddressTheChild", + overlap is not null && ReactorDiagnostics.TrySetState(overlap, 0, "d", out _)); + await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child:d") is not null, + maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_OverlapChildRerendered", H.FindControl(t => t.Text == "shell-child:d") is not null); + + // A root that calls Mount from its own Render(): that pass still commits the old root's + // tree, and its retirement is deferred to the next pass, after which only the + // replacement is described. + var switcher = new SwitchingRoot { Host = host }; + host.Mount(switcher); + await Harness.WaitFor(() => H.FindControl(t => t.Text == "switched-in") is not null, maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_MidPassMountRendersReplacement", H.FindControl(t => t.Text == "switched-in") is not null); + H.Check("RootAnchor_MidPassMountDescribesOnlyTheReplacement", + ReactorDiagnostics.DescribeComponent(target) is { IsRoot: true, Name: "SwitchedIn" }); + + // The root shows an app-supplied render-error fallback: that is not the root's output, + // so neither the container nor the fallback control describes the root. + var fail = false; + var retryNull = false; + var nullRetries = 0; + var fbHost = H.CreateHost(); + fbHost.RenderErrorHandler = _ => TextBlock("root-app-fallback"); + fbHost.Mount(ctx => + { + var (s, _) = ctx.UseState("ok"); + if (fail) throw new InvalidOperationException("root render failed"); + if (retryNull) { nullRetries++; return null!; } + return TextBlock($"root-recovered:{s}"); + }); + await Harness.Render(); + var fbTarget = fbHost.ContentTarget; + // Render once successfully first, so the fallback replaces an already-described root. + H.Check("RootAnchor_RootDescribedBeforeFailure", + fbTarget is not null && ReactorDiagnostics.DescribeComponent(fbTarget) is { IsRoot: true }); + fail = true; + fbHost.RequestRender(); + await Harness.WaitFor(() => H.FindText("root-app-fallback") is not null, maxPasses: 16, perPassMs: 10); + var fallback = H.FindText("root-app-fallback"); + H.Check("RootAnchor_AppFallbackShown", fallback is not null && fbTarget is not null, + $"content={fbTarget?.Child?.GetType().Name ?? "null"}"); + if (fallback is null || fbTarget is null) return; + H.Check("RootAnchor_AppFallbackContainerNotDescribed", ReactorDiagnostics.DescribeComponent(fbTarget) is null); + H.Check("RootAnchor_AppFallbackControlNotDescribed", ReactorDiagnostics.DescribeComponent(fallback) is null); + // A retry that renders null keeps the fallback on screen; it is still not the root's output. + fail = false; + retryNull = true; + fbHost.RequestRender(); + await Harness.Render(); + await Harness.Render(); + H.Check("RootAnchor_NullRetryKeepsFallbackUnattributed", + nullRetries > 0 && ReferenceEquals(H.FindText("root-app-fallback"), fallback) + && ReactorDiagnostics.DescribeComponent(fbTarget) is null + && ReactorDiagnostics.DescribeComponent(fallback) is null); + retryNull = false; + fail = false; + fbHost.RequestRender(); + await Harness.WaitFor(() => H.FindText("root-recovered:ok") is not null, maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_RecoveredRootDescribed", + ReactorDiagnostics.DescribeComponent(fbTarget) is { IsRoot: true, Kind: "function", State: [{ Value: "\"ok\"" }] }); + + // A root that renders Empty() has no rendered control; its ContentTarget still anchors + // it. A private container keeps other (undisposed) hosts out of the picture. + var emptyTarget = new Border(); + H.SetContent(emptyTarget); + using (var emptyHost = new ReactorHost(H.Window) { ContentTarget = emptyTarget }) + { + emptyHost.Mount(ctx => + { + var (s, _) = ctx.UseState("empty-root"); + return Empty(); + }); + await Harness.Render(); + H.Check("RootAnchor_EmptyRootViaContentTarget", + emptyHost.CurrentControl is null + && ReactorDiagnostics.DescribeComponent(emptyTarget) is { IsRoot: true, State: [{ Value: "\"empty-root\"" }] }); + + // An empty container proves nothing by itself: an unrelated empty Border the host + // never mounted into is not claimed… + var unrelated = new Border(); + emptyHost.ContentTarget = unrelated; + H.Check("RootAnchor_EmptyRootDoesNotClaimUnrelatedContainer", ReactorDiagnostics.DescribeComponent(unrelated) is null); + // Re-mounting into it claims the container only together with publishing the new root. + emptyHost.Mount(ctx => + { + var (s, _) = ctx.UseState("retargeted"); + return Empty(); + }); + await Harness.Render(); + H.Check("RootAnchor_RetargetClaimedWithItsRenderedRoot", + ReactorDiagnostics.DescribeComponent(unrelated) is { IsRoot: true, State: [{ Value: "\"retargeted\"" }] }); + emptyHost.ContentTarget = emptyTarget; + emptyHost.Mount(ctx => + { + var (s, _) = ctx.UseState("empty-root"); + return Empty(); + }); + await Harness.Render(); + // Rendering into emptyTarget dropped the claim on the earlier container, so pointing + // the host back at it does not resolve it to the root rendered elsewhere. + emptyHost.ContentTarget = unrelated; + H.Check("RootAnchor_RetargetDropsTheEarlierClaim", ReactorDiagnostics.DescribeComponent(unrelated) is null); + // …and until a render publishes to the new target, the container the root was + // rendered into is still its anchor. + H.Check("RootAnchor_PublishedContainerAnchorsUntilNextRender", + ReactorDiagnostics.DescribeComponent(emptyTarget) is { IsRoot: true, State: [{ Value: "\"empty-root\"" }] }); + emptyHost.ContentTarget = emptyTarget; + + // …and of two empty-root hosts sharing one container, the latest to mount owns it. + using var secondEmpty = new ReactorHost(H.Window) { ContentTarget = emptyTarget }; + secondEmpty.Mount(ctx => + { + var (s, _) = ctx.UseState("second-empty"); + return Empty(); + }); + await Harness.Render(); + H.Check("RootAnchor_SharedEmptyContainerOwnedByLatestMount", + ReactorDiagnostics.DescribeComponent(emptyTarget) is { IsRoot: true, State: [{ Value: "\"second-empty\"" }] }); + + // An Empty() render into a container another host still fills writes nothing, so it + // takes no ownership: the occupied container keeps resolving to the host shown in it. + var occupied = new Border(); + using (var filler = new ReactorHost(H.Window) { ContentTarget = occupied }) + using (var emptyLate = new ReactorHost(H.Window) { ContentTarget = occupied }) + { + filler.Mount(new RootCounter()); + await Harness.Render(); + H.Check("RootAnchor_OccupiedContainerResolvesFiller", IsRoot(ReactorDiagnostics.DescribeComponent(occupied))); + emptyLate.Mount(ctx => + { + var (s, _) = ctx.UseState("late-empty"); + return Empty(); + }); + await Harness.Render(); + H.Check("RootAnchor_EmptyRenderDoesNotClaimOccupiedContainer", + occupied.Child is not null && IsRoot(ReactorDiagnostics.DescribeComponent(occupied))); + } + + // A replacement root whose render returns null releases the previous content and is + // described at its (now empty) container. + var nullTarget = new Border(); + using (var nullHost = new ReactorHost(H.Window) { ContentTarget = nullTarget }) + { + nullHost.Mount(new RootCounter()); + await Harness.Render(); + nullHost.Mount(ctx => + { + var (s, _) = ctx.UseState("null-root"); + return null!; + }); + await Harness.Render(); + H.Check("RootAnchor_NullRenderRootDescribedAtContainer", + nullTarget.Child is null + && ReactorDiagnostics.DescribeComponent(nullTarget) is { IsRoot: true, Kind: "function", State: [{ Value: "\"null-root\"" }] }); + } + + // Overlay teardown writes the (empty) raw content back and reclaims the container, + // even though another host claimed it while the wrapper was installed. + var overlayBefore = ReactorFeatureFlags.HighlightReconcileChanges; + try + { + ReactorFeatureFlags.HighlightReconcileChanges = true; + emptyHost.RequestRender(); + await Harness.Render(); + H.Check("RootAnchor_EmptyRootWrapperInstalled", emptyTarget.Child is not null); + ReactorFeatureFlags.HighlightReconcileChanges = false; + secondEmpty.Mount(ctx => + { + var (s, _) = ctx.UseState("second-empty"); + return Empty(); + }); + emptyHost.RequestRender(); + await Harness.Render(); + await Harness.Render(); + H.Check("RootAnchor_OverlayTeardownReclaimsEmptyContainer", + emptyTarget.Child is null + && ReactorDiagnostics.DescribeComponent(emptyTarget) is { IsRoot: true, State: [{ Value: "\"empty-root\"" }] }); + } + finally { ReactorFeatureFlags.HighlightReconcileChanges = overlayBefore; } + + // Disposing the owner releases its claim, so the table holds no disposed host. + emptyHost.Dispose(); + H.Check("RootAnchor_DisposeReleasesContainerOwnership", !ReactorHost.HasContentTargetOwnerForTest(emptyTarget)); + } + H.SetContent(null); + + // A host without a ContentTarget installs its root as the window content. + var window = H.Window; + var previousContent = window.Content; + try + { + using var bare = new ReactorHost(window); + bare.Mount(new RootCounter()); + await Harness.WaitFor(() => bare.CurrentControl is not null && ReferenceEquals(window.Content, bare.CurrentControl), + maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_WindowContent", + window.Content is UIElement windowContent && IsRoot(ReactorDiagnostics.DescribeComponent(windowContent))); + // With an overlay on, the window content is the overlay wrapper, which anchors the root + // only through the published destination. Requesting a ContentTarget does not move the + // root until the next render publishes there, so the wrapper stays its anchor meanwhile. + var overlayWas = ReactorFeatureFlags.HighlightReconcileChanges; + try + { + ReactorFeatureFlags.HighlightReconcileChanges = true; + bare.RequestRender(); + await Harness.WaitFor(() => window.Content is UIElement c && !ReferenceEquals(c, bare.CurrentControl), + maxPasses: 16, perPassMs: 10); + var shownWrapper = window.Content as UIElement; + H.Check("RootAnchor_WindowOverlayWrapperInstalled", + shownWrapper is not null && !ReferenceEquals(shownWrapper, bare.CurrentControl)); + bare.ContentTarget = new Border(); + H.Check("RootAnchor_WindowContentAnchorsUntilRetargetRenders", + shownWrapper is not null && IsRoot(ReactorDiagnostics.DescribeComponent(shownWrapper))); + } + finally { ReactorFeatureFlags.HighlightReconcileChanges = overlayWas; } + } + finally + { + window.Content = previousContent; + await Harness.Render(); + } + + // ReactorHostControl: the control itself and its content are anchors. + var hostControl = new ReactorHostControl(); + hostControl.Mount(ctx => + { + var (s, _) = ctx.UseState("hc"); + return TextBlock($"host-control:{s}"); + }); + H.SetContent(new Border { Child = hostControl }); + try + { + await Harness.WaitFor(() => hostControl.Content is not null, maxPasses: 16, perPassMs: 10); + var viaControl = ReactorDiagnostics.DescribeComponent(hostControl); + var viaContent = hostControl.Content is UIElement content ? ReactorDiagnostics.DescribeComponent(content) : null; + H.Check("RootAnchor_HostControl", + viaControl is { IsRoot: true, Kind: "function", State: [{ Value: "\"hc\"" }] } + && viaControl.Name.StartsWith("render in ", StringComparison.Ordinal)); + H.Check("RootAnchor_HostControlContent", viaContent is { IsRoot: true, Kind: "function" }); + + // Content is public: an element a consumer swaps in is not attributed to the root. + var rendered = hostControl.Content; + var foreign = new Border(); + hostControl.Content = foreign; + H.Check("RootAnchor_HostControlForeignContentNotDescribed", ReactorDiagnostics.DescribeComponent(foreign) is null); + H.Check("RootAnchor_HostControlStillDescribedAfterSwap", ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true }); + hostControl.Content = rendered; + + // Remount gap: Mount retired the displayed (function) root, so nothing is described + // until the new root renders. + hostControl.Mount(new RootCounter()); + H.Check("RootAnchor_HostControlRemountGap", ReactorDiagnostics.DescribeComponent(hostControl) is null); + await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is { Name: "RootCounter" }, + maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_HostControlRemounted", + ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true, Name: "RootCounter" }); + + // A root that renders Empty() is still anchored at the control itself. + hostControl.Mount(ctx => + { + var (s, _) = ctx.UseState("hc-empty"); + return Empty(); + }); + await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is { State: [{ Value: "\"hc-empty\"" }] }, + maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_HostControlEmptyRoot", + ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true, Kind: "function", State: [{ Value: "\"hc-empty\"" }] }); + + // A root whose render returns null rendered too: its hooks are live and it is + // described at the control. + hostControl.Mount(ctx => + { + var (s, _) = ctx.UseState("hc-null"); + return null!; + }); + await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is { State: [{ Value: "\"hc-null\"" }] }, + maxPasses: 16, perPassMs: 10); + H.Check("RootAnchor_HostControlNullRenderRoot", + ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true, Kind: "function", State: [{ Value: "\"hc-null\"" }] }); + + // Throw → fallback, then a retry that renders null: the fallback stays on screen and + // the control describes nothing. + var hcFail = true; + var hcNull = false; + var hcNullRenders = 0; + Action? hcSet = null; + hostControl.RenderErrorHandler = _ => TextBlock("hc-fallback"); + hostControl.Mount(ctx => + { + var (s, set) = ctx.UseState("hc-retry"); + hcSet = set; + if (hcFail) throw new InvalidOperationException("host control root failed"); + if (hcNull) { hcNullRenders++; return null!; } + return TextBlock($"hc-retry:{s}"); + }); + await Harness.WaitFor(() => H.FindText("hc-fallback") is not null, maxPasses: 16, perPassMs: 10); + var hcFallback = H.FindText("hc-fallback"); + hcFail = false; + hcNull = true; + hcSet?.Invoke("hc-retry-null"); + await Harness.Render(); + await Harness.Render(); + H.Check("RootAnchor_HostControlNullRetryKeepsFallbackUnattributed", + hcNullRenders > 0 && hcFallback is not null && ReferenceEquals(H.FindText("hc-fallback"), hcFallback) + && ReactorDiagnostics.DescribeComponent(hostControl) is null); + hostControl.RenderErrorHandler = null; + + hostControl.Dispose(); + H.Check("RootAnchor_DisposedHostControlNotDescribed", ReactorDiagnostics.DescribeComponent(hostControl) is null); + } + finally + { + H.SetContent(null); + } + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs new file mode 100644 index 000000000..3cc52bf9c --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs @@ -0,0 +1,85 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Diagnostics; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// The realized must point back at +/// its ContentDialog(...) element, so an inspector that picks the dialog's chrome +/// (title, primary/secondary/close buttons) and walks up to the dialog can attribute it. +/// The collapsed placeholder carries the tag too, but it lives in the owner's tree while +/// the dialog is hosted in a popup, so no ancestor walk from a dialog button reaches it. +/// The tagging gate itself is unit-tested in ContentDialogDiagnosticsTagTests. +/// +internal static class ContentDialogTagFixtures +{ + internal class DialogIsTagged(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var previous = ReactorSourceMap.Enabled; + ReactorSourceMap.Enabled = true; + Microsoft.UI.Xaml.Controls.ContentDialog? dialog = null; + try + { + var host = H.CreateHost(); + host.Mount(ctx => + { + var (title, setTitle) = ctx.UseState("TagDialog"); + return VStack( + TextBlock("anchor"), + (ContentDialog(title, Button("Retitle", () => setTitle("TagDialog2")), "OK") with { IsOpen = true }) + .Width(400).LabeledBy("tag-dialog-label")); + }); + + dialog = await ContentDialogProbe.WaitForOpen(H, "TagDialog"); + H.Check("ContentDialogTag_Opened", dialog is not null); + if (dialog is null) return; + + var tag = Reconciler.GetElementTag(dialog) as ContentDialogElement; + H.Check("ContentDialogTag_DialogCarriesItsElement", tag?.Title == "TagDialog"); + + // The tag only attributes the dialog: its modifiers went to the collapsed + // placeholder, so they are not reported as the dialog's own. + H.Check("ContentDialogTag_NoPhantomAppliedProperties", + Microsoft.UI.Reactor.Core.Diagnostics.ReactorDiagnostics.GetAppliedProperties(dialog).Count == 0); + H.Check("ContentDialogTag_NoPhantomReferenceEdges", + Microsoft.UI.Reactor.Core.Diagnostics.ReactorDiagnostics.GetReferenceEdges(dialog).Count == 0); + + // The tag follows re-renders while the dialog stays open, so attribution + // reads the live element rather than the one that opened the dialog. + H.Check("ContentDialogTag_RetitleClicked", await ContentDialogProbe.WaitAndClick(dialog, "Retitle")); + await Harness.WaitFor(() => dialog.Title as string == "TagDialog2"); + H.Check("ContentDialogTag_RetagsOnUpdate", + (Reconciler.GetElementTag(dialog) as ContentDialogElement)?.Title == "TagDialog2"); + +#if REACTOR_SOURCEMAP + H.Check("ContentDialogTag_GetSourceResolves", + ReactorSourceMap.GetSource(dialog)?.FilePath.EndsWith("ContentDialogTagFixtures.cs", StringComparison.Ordinal) == true); +#else + H.Skip("ContentDialogTag_GetSourceResolves", + "assembly built without REACTOR_SOURCEMAP (Release) - no call site is stamped"); +#endif + + // A caller can keep the dialog (.Set(...)); once closed it must not keep + // resolving to its element. + var closed = dialog; + dialog = null; + closed.Hide(); + await ContentDialogProbe.WaitForNoneOpen(H); + await Harness.WaitFor(() => Reconciler.GetElementTag(closed) is null); + H.Check("ContentDialogTag_ClearedOnClose", Reconciler.GetElementTag(closed) is null); + } + finally + { + ReactorSourceMap.Enabled = previous; + if (dialog is not null) + { + dialog.Hide(); + await ContentDialogProbe.WaitForNoneOpen(H); + } + } + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs new file mode 100644 index 000000000..3d534efb6 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -0,0 +1,175 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Core.Diagnostics; +using Microsoft.UI.Reactor.Hooks; +using Microsoft.UI.Xaml.Controls; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// The live half of : a reference whose +/// target is not mounted is reported pending (while the WinUI property reads null), and the +/// same edge flips to resolved — pointing at the real control — once the target mounts. +/// The projection itself is unit-tested in ReactorDiagnosticsReferenceEdgesTests. +/// +internal static class ReferenceEdgeFixtures +{ + internal class PendingThenResolved(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + Action? setShowTarget = null; + host.Mount(ctx => + { + var (showTarget, set) = ctx.UseState(false); + setShowTarget = set; + var target = ctx.UseElementRef