From d8b09e4187149efd1fabe758505064bfd76ade3d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 12:42:52 -0700 Subject: [PATCH 01/47] Add text-only component inspection to ReactorDiagnostics An inspector (winapp devtools, Reactor.Devtools) can now go from a realized control to the component behind it without reflecting over reconciler internals or holding live Reactor objects: - DescribeComponent(UIElement) -> ComponentSnapshot: name, kind, root flag, props, hooks (State) and consumed contexts, every value as text. - TrySetState(UIElement, hookIndex, text, out error): parses text to the hook's type (numbers, char, bool, enums, dates, times, GUIDs, strings and nullables; "null" for strings and nullables), writes it and re-renders like the setter; refuses other types, non-state hooks and secrets with a reason. - Rerender(UIElement): re-renders the component, bypassing memoization. - GetAppliedProperties(UIElement): the WinUI properties a control's common modifiers set, plus the caption-derived default AutomationProperties.Name. - GetReferenceEdges(UIElement): the references a control declares, including pending ones whose target has not mounted. A resolved edge's Target is the one live object (the mounted control). Reactor owns formatting (quoted strings, collections summarised from the count they advertise - never enumerated - and truncation) and redaction (SecureString; types, element types and names ending in Password, Secret, Credential, Token, ApiKey, PrivateKey or ConnectionString; and objects whose own text names such a member). Lookup goes through a weak per-host reconciler registry and a host root resolver that only claims a container while its own content is installed there. Hook cells and contexts describe themselves via internal virtuals; RenderContext.SnapshotHooks now uses the same seams (no reflection) and Reactor.Devtools' count probe shares DiagnosticText.TryCount. The only reflection is guarded reads of props members and collection Count, which degrade - never to ToString() - when trimmed. All reads are on demand and UI-thread-affine, with no render-path cost. With source mapping on, an open ContentDialog is tagged with its element so its chrome resolves to the ContentDialog(...) call site. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 15 + docs/_pipeline/templates/source-mapping.md.dt | 69 ++ docs/guide/source-mapping.md | 69 ++ .../reactor-dsl/references/reactor.api.txt | 61 ++ skills/reactor.api.txt | 61 ++ src/Reactor.Devtools/DevtoolsStateTool.cs | 24 +- src/Reactor.Devtools/ReferenceOverlay.cs | 19 +- src/Reactor/Core/Component.cs | 10 + src/Reactor/Core/Context.cs | 6 + .../Core/Diagnostics/AppliedModifierMap.cs | 182 +++++ .../Core/Diagnostics/DiagnosticText.cs | 358 +++++++++ .../ReactorDiagnostics.Components.cs | 443 +++++++++++ .../ReactorDiagnostics.References.cs | 143 ++++ .../Core/Diagnostics/ReactorDiagnostics.cs | 2 +- src/Reactor/Core/Reconciler.Diagnostics.cs | 133 ++++ src/Reactor/Core/Reconciler.cs | 21 +- src/Reactor/Core/RenderContext.cs | 237 ++++-- .../Core/V1Protocol/OverlayLifecycle.cs | 29 + .../Core/V1Protocol/ReactorBindingT.cs | 2 +- src/Reactor/Core/V1Protocol/ReferenceEdge.cs | 34 + src/Reactor/Hosting/ReactorHost.cs | 37 + .../Hosting/ReactorHostControl.Diagnostics.cs | 33 + src/Reactor/Hosting/ReactorHostControl.cs | 1 + .../Fixtures/ComponentInspectionFixtures.cs | 227 ++++++ .../Fixtures/ContentDialogTagFixtures.cs | 68 ++ .../Fixtures/ReferenceEdgeFixtures.cs | 58 ++ .../SelfTest/SelfTestFixtureRegistry.cs | 8 + .../ContentDialogDiagnosticsTagTests.cs | 49 ++ .../Diagnostics/DiagnosticTextTests.cs | 309 ++++++++ .../ReactorDiagnosticsComponentsTests.cs | 698 ++++++++++++++++++ ...orDiagnosticsDefaultAutomationNameTests.cs | 72 ++ .../ReactorDiagnosticsReferenceEdgesTests.cs | 146 ++++ 32 files changed, 3528 insertions(+), 96 deletions(-) create mode 100644 src/Reactor/Core/Diagnostics/AppliedModifierMap.cs create mode 100644 src/Reactor/Core/Diagnostics/DiagnosticText.cs create mode 100644 src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs create mode 100644 src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs create mode 100644 src/Reactor/Core/Reconciler.Diagnostics.cs create mode 100644 src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs create mode 100644 tests/Reactor.Tests/Diagnostics/ContentDialogDiagnosticsTagTests.cs create mode 100644 tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs create mode 100644 tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs create mode 100644 tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs create mode 100644 tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 786f48e03..10ffd650e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,21 @@ 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 — with secrets + redacted; a resolved reference edge carries its mounted target control. On-demand + reads only, + UI-thread-affine, no render-path cost. 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). - **Getting Started documents the single-file path.** A Reactor app does not need a `.csproj`: .NET 10 runs a lone `.cs` file whose `#:package` / `#:property` header supplies what a project file otherwise would. The header also references diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 698d1cf5d..c643bb5c7 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -265,6 +265,75 @@ your own types declare — not just `string`. Two limits are worth knowing: `[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. Every member +must be called on the UI 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", "... is a ref hook; ..." + + 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)`), long values are cut at 200 characters, +and secrets — a `SecureString`; a type, collection element type or member name +ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or +`ConnectionString`; an object whose own text, or a string, names such a member +with a value (`AccessToken=…`, a record with a `Password` property) — are +reported as +`` and are never editable. +`TrySetState` accepts numbers, single characters, `true`/`false`, enum member +names, 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. + +Component lookup does not depend on source mapping. `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. + +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 40182bf84..b63e095e8 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -325,6 +325,75 @@ your own types declare — not just `string`. Two limits are worth knowing: `[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. Every member +must be called on the UI 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", "... is a ref hook; ..." + + 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)`), long values are cut at 200 characters, +and secrets — a `SecureString`; a type, collection element type or member name +ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or +`ConnectionString`; an object whose own text, or a string, names such a member +with a value (`AccessToken=…`, a record with a `Password` property) — are +reported as +`` and are never editable. +`TrySetState` accepts numbers, single characters, `true`/`false`, enum member +names, 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. + +Component lookup does not depend on source mapping. `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. + +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 7b1705f5f..dc2b3dbf2 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -1237,6 +1237,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; } @@ -1729,6 +1740,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[] @@ -1950,6 +1975,22 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record DiagnosticValue +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool Redacted, bool MigratedByHotReload) +bool Editable { get; init; } +bool MigratedByHotReload { get; init; } +bool Redacted { 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 Redacted, 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; } @@ -4553,6 +4594,11 @@ Register(IReactorDevtoolsHost host) → void ### static class ReactorDiagnostics IReadOnlyList RecentKeyedListWarnings { get; } ClearRecentKeyedListWarnings() → void +DescribeComponent(UIElement element) → ComponentSnapshot +GetAppliedProperties(UIElement control) → 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; } @@ -4727,6 +4773,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 7b1705f5f..dc2b3dbf2 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -1237,6 +1237,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; } @@ -1729,6 +1740,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[] @@ -1950,6 +1975,22 @@ Equals(object obj) → bool GetHashCode() → int ToString() → string +### record DiagnosticValue +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool Redacted, bool MigratedByHotReload) +bool Editable { get; init; } +bool MigratedByHotReload { get; init; } +bool Redacted { 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 Redacted, 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; } @@ -4553,6 +4594,11 @@ Register(IReactorDevtoolsHost host) → void ### static class ReactorDiagnostics IReadOnlyList RecentKeyedListWarnings { get; } ClearRecentKeyedListWarnings() → void +DescribeComponent(UIElement element) → ComponentSnapshot +GetAppliedProperties(UIElement control) → 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; } @@ -4727,6 +4773,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..b2cb39160 --- /dev/null +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -0,0 +1,182 @@ +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), and Scale/Rotation/Translation/ +/// CenterPoint (composition-backed facades, not DPs). +/// 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")), + 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) + { + List? result = null; + foreach (var entry in Entries) + { + if (entry.Get(modifiers) is not { } value) continue; + if (entry.Target(controlType) is not { } property) continue; + (result ??= new()).Add(new AppliedModifier(entry.Modifier, property, value)); + } + return result is null ? global::System.Array.Empty() : result; + } + + /// Modifier name reported for the caption-derived default name. + internal const string DefaultAutomationNameModifier = "DefaultAutomationName"; + + /// + /// 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 + /// caption is indistinguishable from the default and is reported as the default. + /// + internal static AppliedModifier? DescribeDefaultAutomationName(Element source, string? liveName) + { + // An explicit .AutomationName(...) is reported as itself and suppresses the default. + if (source.Modifiers?.AutomationName is not null) 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..1bb56c1a3 --- /dev/null +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -0,0 +1,358 @@ +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), secret redaction 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. Mirrors the winapp DevTools agent's +/// ReactorComponents / ReactorElementProps rules so its output is unchanged. +/// +internal static class DiagnosticText +{ + internal const string Redacted = ""; + internal const int MaxValueLength = 200; + + /// int, string?, 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.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) + "[]"; + 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; + return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || + type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid); + } + + /// Whether a value of this type is a secret: a SecureString or a password-, secret- or credential-like type. + internal static bool IsSecretType(Type? type) + { + if (type is null) return false; + type = Nullable.GetUnderlyingType(type) ?? type; + return type.FullName == "System.Security.SecureString" || IsSecretName(TypeName(type)); + } + + /// + /// , or a collection of secrets: an array whose element type, or a + /// generic type with a type argument, that is a secret (List<UserCredential>). + /// + internal static bool HoldsSecretType(Type? type) + { + if (type is null) return false; + if (IsSecretType(type)) return true; + if (type.IsArray && type.GetElementType() is { } element) return HoldsSecretType(element); + if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments()) + if (HoldsSecretType(argument)) return true; + } + return false; + } + + /// + /// Whether a member or type name says its value is a secret: it ends in Password, + /// Secret, Credential, Token, ApiKey, PrivateKey or + /// ConnectionString (any case). A bare Key is not enough — WinUI's + /// AccessKey is an ordinary property. + /// + internal static bool IsSecretName(string? name) + { + if (name is not { Length: > 0 }) return false; + foreach (var suffix in s_secretSuffixes) + if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static readonly string[] s_secretSuffixes = + ["Password", "Secret", "Credential", "Token", "ApiKey", "PrivateKey", "ConnectionString"]; + + // A member of a secret name followed by a value, inside an object's own text or a string: + // "Password = …" in a record's compiler-generated ToString(), "ApiSecret: …" in a hand-written + // one, "AccessToken=…" in a connection-style string. A bare label ("Password:") has no value + // after the separator and does not match. + private static readonly global::System.Text.RegularExpressions.Regex s_secretMemberInText = new( + @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)\s*[=:]\s*[^\s,;}\]]", + global::System.Text.RegularExpressions.RegexOptions.IgnoreCase | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// A component value (prop, hook, context) as shown to the developer: redacted when its name + /// or type says it is a secret, collections summarised, strings quoted, long text cut. + /// + internal static (string Text, bool Redacted) Format(string name, Type? declared, object? value) + { + if (value is not null && (IsSecretName(name) || HoldsSecretType(declared) || HoldsSecretType(value.GetType()))) + return (Redacted, true); + + if (value is not null && IsListLike(value)) + { + // 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)} items" + : "count unknown"; + return ($"{FriendlyTypeName(shownType)} ({countText})", false); + } + + if (value is string s) + { + // A string can carry a labelled secret itself ("AccessToken=…", a connection string). + if (s_secretMemberInText.IsMatch(s)) return (Redacted, true); + return (Truncate(Quote(s)), false); + } + + return FormatPlain(name, value); + } + + /// + /// A value as an element record or modifier carries it: unquoted, redacted when the name says + /// it is a secret or when the value's own text shows a secret-named member, long text cut. + /// Used for applied properties. + /// + internal static (string Text, bool Redacted) FormatPlain(string name, object? value) + { + if (IsSecretName(name) && value is not null and not Delegate) + return (Redacted, true); + + 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; + case IFormattable f: + text = OwnText(f, () => f.ToString(null, CultureInfo.InvariantCulture)); + break; + default: + // An object's own text can print its members — a record's ToString() does — so a + // secret one would leak through it. Withhold the whole value instead. + text = OwnText(value, value.ToString); + if (s_secretMemberInText.IsMatch(text)) return (Redacted, true); + break; + } + return (Truncate(text), false); + } + + /// + /// 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})"; + } + } + + /// + /// 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 = $"a {FriendlyTypeName(type)} value cannot be typed as 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))) + { + value = parsed; + return true; + } + error = $"'{text}' is not a {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(), which could print a secret member), so it degrades rather than fails.")] + internal static IReadOnlyList<(string Name, Type Type, object? Value)> PropRows(object? props, Type? declared) + { + if (props is null) return global::System.Array.Empty<(string, Type, object?)>(); + var type = props.GetType(); + if (type.IsPrimitive || props is string or decimal or Enum || IsListLike(props)) + return new[] { ("Props", declared ?? type, (object?)props) }; + + var rows = new List<(string, Type, object?)>(); + foreach (var property in type.GetProperties(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance)) + { + if (property.Name == "EqualityContract" || property.GetIndexParameters().Length > 0 || !property.CanRead) + continue; + object? value; + try { value = property.GetValue(props); } + catch (global::System.Reflection.TargetInvocationException) { continue; } + 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(), which for a record would print every member, secrets included. + 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. + private static bool IsListLike(object value) => + value is ICollection || + (value is IEnumerable and not string && OwnText(value, value.ToString) == value.GetType().ToString()); + + /// + /// 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); + if (count is not null && count.PropertyType == typeof(int) && count.GetIndexParameters().Length == 0) + 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) => + "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal).Replace("\r", "\\r", StringComparison.Ordinal) + "\""; + + private static string Truncate(string text) => + text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "..." : text; +} diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs new file mode 100644 index 000000000..10625208e --- /dev/null +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -0,0 +1,443 @@ +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. 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, string?, +/// List<TaskItem>). 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, secrets +/// <redacted>. 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 that is not a secret. +/// True when was withheld because the value is a +/// secret: a SecureString; a type, collection element type or name ending in Password, +/// Secret, Credential, Token, ApiKey, PrivateKey or ConnectionString; or a string or object whose own text names such a member with a value (AccessToken=…, a record with a +/// Password property). +/// 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 Redacted, + 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. + public static ComponentSnapshot? DescribeComponent(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + EnsureUIThread(); + 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; secrets never are. + /// Must be called on the UI thread that renders the component. + /// + public static bool TrySetState(UIElement element, int hookIndex, string text, out string? error) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(text); + EnsureUIThread(); + 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. + public static bool Rerender(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + EnsureUIThread(); + 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. + /// + public static IReadOnlyList GetAppliedProperties(UIElement control) + { + ArgumentNullException.ThrowIfNull(control); + EnsureUIThread(); + var tag = Reconciler.GetElementTag(control); + if (tag is null) return global::System.Array.Empty(); + var source = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag; + + var applied = new List(); + if (source.Modifiers is { } modifiers) + { + foreach (var m in AppliedModifierMap.Describe(modifiers, control.GetType())) + applied.Add(ToText(m)); + } + + if (AppliedModifierMap.DescribeDefaultAutomationName( + source, Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(control)) is { } defaultName) + applied.Add(ToText(defaultName)); + + return applied; + } + + internal static AppliedProperty ToText(AppliedModifier m) + => new(m.Modifier, m.Property, DiagnosticText.FormatPlain(m.Modifier, m.Value).Text); + + /// + /// 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. Shares ThreadAffinity's rule: unchecked before a UI dispatcher has + /// been captured (headless tests, pre-Run). + /// + internal static void EnsureUIThread() + => 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]; + var (text, redacted) = DiagnosticText.Format(name, type, value); + values[i] = new DiagnosticValue(i, name, "prop", DiagnosticText.FriendlyTypeName(type), text, + Editable: false, redacted, 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 = ""; + bool redacted = false; + if (cell.DiagnosticKind is not (HookKind.Effect or HookKind.NavigationLifecycle or HookKind.Unknown)) + { + // A context hook's secret-ness can come from the context's name. + var redactionName = cell.DiagnosticKind == HookKind.Context + ? contexts?.FirstOrDefault(c => c.Index == i)?.Name ?? "" + : ""; + (text, redacted) = DiagnosticText.Format(redactionName, valueType, cell.DiagnosticValue); + } + values[i] = new DiagnosticValue(i, "", HookKindText(cell.DiagnosticKind), + valueType is null ? "" : DiagnosticText.FriendlyTypeName(valueType), text, + Editable: cell.DiagnosticCanSet && !redacted && DiagnosticText.IsEditable(valueType), + redacted, 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; } + var (text, redacted) = DiagnosticText.Format(name, ctx.ValueType, value); + (result ??= new()).Add(new DiagnosticValue(i, name, isDefault ? "default" : "provided", + DiagnosticText.FriendlyTypeName(ctx.ValueType), text, Editable: false, redacted, 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}> is a {HookKindText(cell.DiagnosticKind)} hook; 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.IsSecretType(valueType) || DiagnosticText.Format("", valueType, cell.DiagnosticValue).Redacted) + { + error = $"hook {hookIndex} of <{Name}> holds a secret; it is not written from diagnostics"; + 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.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs new file mode 100644 index 000000000..a8c8bc558 --- /dev/null +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -0,0 +1,143 @@ +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). +/// 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. Ordered by + /// property, then list position. + /// + /// + /// 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 when the control is tagged with its element (turn source mapping on). Reads existing + /// reconciler state on demand. Must be called on the UI thread. + /// + public static IReadOnlyList GetReferenceEdges(UIElement control) + { + ArgumentNullException.ThrowIfNull(control); + EnsureUIThread(); + + 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 + && Reconciler.GetElementTag(control) is { } tag + && (Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag) + .Modifiers?.Accessibility?.LabeledBy is { } id) + { + authoredLabeledBy = id; + if (Microsoft.UI.Xaml.Automation.AutomationProperties.GetLabeledBy(control) is { } live + && 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 +{ + 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) + { + for (int i = 0; i < listEdge.Cells.Count; i++) + (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, listEdge.Cells[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(); + result.Sort(static (a, b) => a.Slot != b.Slot ? a.Slot.CompareTo(b.Slot) : 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/Diagnostics/ReactorDiagnostics.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.cs index 7b977e9ba..3fe2302aa 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.cs @@ -66,7 +66,7 @@ public sealed record KeyedListDiagnostic( /// Reads are lock-free against a snapshot; writes take a short lock. /// /// -public static class ReactorDiagnostics +public static partial class ReactorDiagnostics { /// Maximum number of distinct (control, kind, sample-set) entries /// retained. New entries beyond this drop the oldest. diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs new file mode 100644 index 000000000..96537866c --- /dev/null +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -0,0 +1,133 @@ +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 has to know +/// every live reconciler. The registry below is the only cost this adds to a running +/// app: one weak reference per reconciler (i.e. per host), taken at construction and +/// dropped at . Nothing is recorded per component or per render; +/// every query is resolved on demand. +/// +public sealed partial class Reconciler +{ + private static readonly object s_liveGate = new(); + private static readonly List> s_live = new(); + + /// + /// 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; } + + private void RegisterForDiagnostics() + { + lock (s_liveGate) + { + // Prune collected entries on the way in so the list tracks live hosts, + // not every reconciler a long-running process (or test run) ever created. + s_live.RemoveAll(static w => !w.TryGetTarget(out _)); + s_live.Add(new WeakReference(this)); + } + } + + private void UnregisterForDiagnostics() + { + lock (s_liveGate) + { + s_live.RemoveAll(w => !w.TryGetTarget(out var r) || ReferenceEquals(r, this)); + } + } + + private static List SnapshotLiveReconcilers() + { + lock (s_liveGate) + { + var list = new List(s_live.Count); + foreach (var w in s_live) + if (w.TryGetTarget(out var r)) list.Add(r); + return list; + } + } + + /// Test seam: whether this reconciler is in the live registry. + internal bool IsRegisteredForDiagnostics + { + get + { + lock (s_liveGate) + { + foreach (var w in s_live) + if (w.TryGetTarget(out var r) && ReferenceEquals(r, this)) return true; + return false; + } + } + } + + /// + /// 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 SnapshotLiveReconcilers()) + { + 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 SnapshotLiveReconcilers()) + { + 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); diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index d7ca34d13..e56e12eb2 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -336,6 +336,7 @@ public static bool EnableBitmaskDiff public Reconciler(ILogger? logger = null) { _logger = logger; + RegisterForDiagnostics(); // Spec 048 §3.4 — no more bootstrap. Built-in handlers register // themselves lazily on first factory call (per-control Reg<>/ // RegDecorator<> cctor latch in `Dsl.cs`), or callers register @@ -3156,13 +3157,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 @@ -3178,13 +3190,12 @@ 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; Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(fe, trimmed); } @@ -6078,6 +6089,8 @@ internal void ForEachLiveContext(Action action) public void Dispose() { + _disposedForDiagnostics = true; + UnregisterForDiagnostics(); foreach (var node in _componentNodes.Values) { node.Context?.RunCleanups(); diff --git a/src/Reactor/Core/RenderContext.cs b/src/Reactor/Core/RenderContext.cs index ac2a3aa90..28695b445 100644 --- a/src/Reactor/Core/RenderContext.cs +++ b/src/Reactor/Core/RenderContext.cs @@ -2559,72 +2559,75 @@ 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 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<>) - ? "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 + var kind = h.DiagnosticKind; + string hookName = kind switch { - hookName = t.Name; - } - + HookKind.State or HookKind.Reducer => "useState", + HookKind.Ref => "useRef", + 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 @@ -2632,10 +2635,104 @@ 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 { + internal override HookKind DiagnosticKind => + typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Ref<>) + ? HookKind.Ref + : SetterKind is SetterKindReducer or SetterKindDispatch + ? HookKind.Reducer + : HookKind.State; + // A ref cell reports what it points at (Ref.Current, typed T), not the Ref box. + internal override Type? DiagnosticValueType => 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 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. @@ -2667,6 +2764,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; @@ -2677,18 +2775,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; @@ -2708,6 +2813,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; @@ -2757,8 +2874,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 2605fa310..87496f129 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,33 @@ 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; + private static async void ShowContentDialogCore(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, XamlRoot? xamlRoot, Action requestRerender) { var dialog = new WinUI.ContentDialog @@ -216,6 +244,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 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..57873f534 100644 --- a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs +++ b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs @@ -44,6 +44,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/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index ab7e45fb9..8930501be 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -175,6 +175,42 @@ 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. + /// + private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) + { + if (_disposed) return null; + var control = _currentControl; + if (control is null) return null; + + UIElement? container = ContentTarget ?? (_windowClosed ? null : _window.Content as UIElement); + UIElement? installed = ContentTarget is { } target ? target.Child : container; + var wrapper = _overlayWiring?.WrapperRoot; + bool ours = ReferenceEquals(installed, control) || (wrapper is not null && ReferenceEquals(installed, wrapper)); + + bool isAnchor = ReferenceEquals(element, control) + || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); + if (!isAnchor) return null; + + var component = _rootComponent; + var funcContext = _funcContext; + if (component is null && funcContext is null) return null; + return new RootComponentSource( + component, + component is null ? funcContext : null, + component is null ? _rootRenderFunc : null, + control, + _currentTree, + () => !_disposed && ReferenceEquals(_rootComponent, component) && ReferenceEquals(_funcContext, funcContext)); + } + /// /// 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 @@ -192,6 +228,7 @@ public ReactorHost(Window window, ILogger? logger = null) // Microsoft.Extensions.Logging call paths. _logger = logger ?? ReactorApp.AppLogger; _reconciler = new Reconciler(_logger); + _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _window = window; _backdropApplier = new BackdropApplier(window); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); diff --git a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs new file mode 100644 index 000000000..1288d0398 --- /dev/null +++ b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs @@ -0,0 +1,33 @@ +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, its current content (which is a dev-overlay wrapper while + /// one is installed), or the rendered root control. + /// + private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) + { + if (_disposed) return null; + var control = _currentControl; + bool isAnchor = ReferenceEquals(element, this) + || ReferenceEquals(element, control) + || ReferenceEquals(element, Content); + if (!isAnchor || control is null) return null; + + var component = _rootComponent; + var funcContext = _funcContext; + if (component is null && funcContext is null) return null; + return new RootComponentSource( + component, + component is null ? funcContext : null, + component is null ? _rootRenderFunc : null, + control, + _currentTree, + () => !_disposed && ReferenceEquals(_rootComponent, component) && ReferenceEquals(_funcContext, funcContext)); + } +} diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index f7a76a5ea..ffd094d4b 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -144,6 +144,7 @@ public ReactorHostControl(Component? component = null, ILogger? logger = null) // already-constructed controls. _logger = logger ?? ReactorApp.AppLogger; _reconciler = new Reconciler(_logger); + _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); // A standalone ReactorHostControl has no ReactorApp bootstrap, so nothing else // sets ReactorApp.UIDispatcher. Cross-thread setState — including the re-render 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..4c835572c --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -0,0 +1,227 @@ +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 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); + var counter = ReactorDiagnostics.DescribeComponent(wrapper); + H.Check("CompInspect_ClassDescribed", + counter is { Name: "Counter", Kind: "class", IsRoot: false }); + H.Check("CompInspect_ClassProps", + counter is not null + && counter.Props.Count == 2 + && counter.Props[0] is { Name: "Step", Type: "int", Value: "2", Redacted: false } + && counter.Props[1] is { Name: "AdminPassword", Value: "", Redacted: true }); + 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"); + + // ── 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}"); + } + } + + /// + /// 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. + /// + 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))); + + // 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 host without a ContentTarget installs its root as the window content. + var window = H.Window; + var previousContent = window.Content; + var bare = new ReactorHost(window); + try + { + 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))); + } + finally + { + bare.Dispose(); + 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" }); + + 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..bf8dda34a --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs @@ -0,0 +1,68 @@ +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 ReactorDiagnosticsFollowupsTests. +/// +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 }); + }); + + 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 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 + } + 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..c68398ee1 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -0,0 +1,58 @@ +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 /// Must be called on the UI thread. + [Microsoft.UI.Reactor.Hosting.UIThreadOnly] public static ComponentSnapshot? DescribeComponent(UIElement element) { ArgumentNullException.ThrowIfNull(element); @@ -112,6 +113,7 @@ public static partial class ReactorDiagnostics /// Only state, reducer and persisted hooks of a text-typed value can be set; secrets never are. /// 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); @@ -136,6 +138,7 @@ public static bool TrySetState(UIElement element, int hookIndex, string text, ou /// 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); @@ -159,6 +162,7 @@ public static bool Rerender(UIElement element) /// 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); diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index a8c8bc558..f703be02c 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -47,6 +47,7 @@ public static partial class ReactorDiagnostics /// only when the control is 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); diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index e56e12eb2..63e135438 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -515,6 +515,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/ @@ -4893,10 +4895,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; } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs index c68398ee1..b2cdacb98 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -55,4 +55,37 @@ public override async Task RunAsync() H.Check("RefEdges_NoEdgesOnPlainControl", target is not null && ReactorDiagnostics.GetReferenceEdges(target).Count == 0); } } -} + + /// + /// An AutomationId .LabeledBy("id") whose target never appears stays reported as a + /// pending edge after the control loads — even with source mapping off, where the control is + /// not tagged and the deferred request itself is the only record of what the author wrote. + /// + internal class PendingAutomationIdSurvivesLoaded(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var previous = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled; + Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled = false; + try + { + var host = H.CreateHost(); + host.Mount(ctx => VStack(TextBox(placeholderText: "refedge-labelled").LabeledBy("refedge-missing-label"))); + await Harness.Render(); + await Harness.Render(50); + + var box = H.FindControl(t => t.PlaceholderText == "refedge-labelled"); + H.Check("RefEdges_LabelledMounted", box is not null && box.IsLoaded); + if (box is null) return; + + var edge = ReactorDiagnostics.GetReferenceEdges(box).SingleOrDefault(e => e.Property == "LabeledBy"); + H.Check("RefEdges_PendingAutomationIdAfterLoaded", + edge is { TargetAutomationId: "refedge-missing-label", IsResolved: false, Target: null }); + } + finally + { + Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled = previous; + } + } + } +} \ No newline at end of file diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 117dbeb08..1e6ddd40c 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -1779,6 +1779,7 @@ internal static class SelfTestFixtureRegistry "ReferenceOverlay_UnresolvedDiagnostic", "ReferenceOverlay_CycleDiagnostic", "Diagnostics_ReferenceEdges", + "Diagnostics_ReferenceEdgesPendingId", // Spec 057 §10 — first-party TeachingTip.Target reference proof. "TeachingTip_TargetReferenceResolvesBothMountOrders", "TeachingTip_SurfaceParity", @@ -3737,6 +3738,7 @@ internal static string[] StaleTierDeclarations() => "ReferenceOverlay_UnresolvedDiagnostic" => new ReferenceOverlaySelfHostFixtures.UnresolvedDiagnostic(harness), "ReferenceOverlay_CycleDiagnostic" => new ReferenceOverlaySelfHostFixtures.CycleDiagnostic(harness), "Diagnostics_ReferenceEdges" => new ReferenceEdgeFixtures.PendingThenResolved(harness), + "Diagnostics_ReferenceEdgesPendingId" => new ReferenceEdgeFixtures.PendingAutomationIdSurvivesLoaded(harness), "TeachingTip_TargetReferenceResolvesBothMountOrders" => new TeachingTipTargetFixtures.TargetReferenceResolvesBothMountOrders(harness), "TeachingTip_SurfaceParity" => new RefNodeSurfaceParityFixtures.Execution(harness), "RealRef_TeachingTipTarget_CrossSubtree" => new RealRefTortureFixtures.TeachingTipTargetCrossSubtree(harness), diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 0beb5ab4e..326b02cda 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -231,6 +231,21 @@ public void FormatPlain_DoesNotQuote_ButStillRedacts() Assert.Equal(("", true), DiagnosticText.FormatPlain("Password", "x")); } + private sealed class FormattableSecret : IFormattable + { + public string ToString(string? format, IFormatProvider? provider) => "ApiKey: hunter2"; + } + + [Fact] + public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() + { + Assert.Equal(("", true), DiagnosticText.FormatPlain("ToolTip", "AccessToken=hunter2")); + Assert.Equal(("", true), DiagnosticText.FormatPlain("", new FormattableSecret())); + Assert.Equal(("", true), DiagnosticText.FormatPlain("", new HandWritten("Password = hunter2"))); + // A bare label is ordinary UI text. + Assert.Equal(("Password:", false), DiagnosticText.FormatPlain("ToolTip", "Password:")); + } + // ── Parsing ───────────────────────────────────────────────────── public static IEnumerable Parsed() => From 43dfb007776f071010638df2eb88763493c97a16 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 15:58:51 -0700 Subject: [PATCH 03/47] Address code-quality review: LINQ filters and using declaration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/AppliedModifierMap.cs | 13 +++++-------- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 12 +++++------- src/Reactor/Core/Reconciler.Diagnostics.cs | 8 +++----- .../Fixtures/ComponentInspectionFixtures.cs | 3 +-- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index b2cb39160..affb74fe2 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -147,14 +147,11 @@ internal static class AppliedModifierMap internal static IReadOnlyList Describe(ElementModifiers modifiers, Type controlType) { - List? result = null; - foreach (var entry in Entries) - { - if (entry.Get(modifiers) is not { } value) continue; - if (entry.Target(controlType) is not { } property) continue; - (result ??= new()).Add(new AppliedModifier(entry.Modifier, property, value)); - } - return result is null ? global::System.Array.Empty() : result; + 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. diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 0ecaa391a..6356fb72d 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -296,14 +296,12 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul return new[] { ("Props", declared ?? type, (object?)props) }; var rows = new List<(string, Type, object?)>(); - foreach (var property in type.GetProperties(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance)) + 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.CanRead); + foreach (var property in readable) { - if (property.Name == "EqualityContract" || property.GetIndexParameters().Length > 0 || !property.CanRead) - continue; - object? value; - try { value = property.GetValue(props); } - catch (global::System.Reflection.TargetInvocationException) { continue; } - rows.Add((property.Name, property.PropertyType, value)); + try { rows.Add((property.Name, property.PropertyType, property.GetValue(props))); } + catch (global::System.Reflection.TargetInvocationException) { } } // No readable members (none declared, or their metadata trimmed away): name the type rather // than fall back to ToString(), which for a record would print every member, secrets included. diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 96537866c..3298fb9a4 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -51,8 +51,8 @@ private static List SnapshotLiveReconcilers() lock (s_liveGate) { var list = new List(s_live.Count); - foreach (var w in s_live) - if (w.TryGetTarget(out var r)) list.Add(r); + foreach (var r in s_live.Select(static w => w.TryGetTarget(out var target) ? target : null).Where(static r => r is not null)) + list.Add(r!); return list; } } @@ -64,9 +64,7 @@ internal bool IsRegisteredForDiagnostics { lock (s_liveGate) { - foreach (var w in s_live) - if (w.TryGetTarget(out var r) && ReferenceEquals(r, this)) return true; - return false; + return s_live.Any(w => w.TryGetTarget(out var r) && ReferenceEquals(r, this)); } } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 4c835572c..84ba3f495 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -181,9 +181,9 @@ static bool IsRoot(ComponentSnapshot? s) => // A host without a ContentTarget installs its root as the window content. var window = H.Window; var previousContent = window.Content; - var bare = new ReactorHost(window); 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); @@ -192,7 +192,6 @@ await Harness.WaitFor(() => bare.CurrentControl is not null && ReferenceEquals(w } finally { - bare.Dispose(); window.Content = previousContent; await Harness.Render(); } From 1ba8a42c9b0e9fe6cd2f657d4060d32f5c0bd694 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 16:25:55 -0700 Subject: [PATCH 04/47] Docs: snippet-back the component inspection example Moves the inline ReactorDiagnostics example into the compiled diagnostics doc app so InlineSnippetLedgerTests passes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/apps/diagnostics/App.cs | 29 +++++++++++++++++++ docs/_pipeline/templates/source-mapping.md.dt | 19 +----------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/_pipeline/apps/diagnostics/App.cs b/docs/_pipeline/apps/diagnostics/App.cs index da19cc4bd..b4e9fbd09 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", "... is a ref hook; ..." + + 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 c643bb5c7..ff85d817b 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -275,24 +275,7 @@ 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", "... is a ref hook; ..." - - 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); +```csharp snippet="diagnostics/inspect-component" ``` Values are formatted for display: strings are quoted, collections are summarised From f1c49ed8af9ea855bf6059c9bc5fc02c341cc953 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 16:38:53 -0700 Subject: [PATCH 05/47] Selftest: accept trimmed props metadata under NativeAOT Under NativeAOT the props record's property metadata is trimmed, so Props degrades to a single '(members unavailable)' row. The fixture now accepts that on AOT (still asserting the secret never leaks) and the docs say so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/templates/source-mapping.md.dt | 4 +++- docs/guide/source-mapping.md | 4 +++- .../Diagnostics/ReactorDiagnostics.Components.cs | 5 +++-- .../SelfTest/Fixtures/ComponentInspectionFixtures.cs | 12 +++++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index ff85d817b..6ffd60e6d 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -291,7 +291,9 @@ reported as names, 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. +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. `GetAppliedProperties` reads the element back-pointer, so it needs the control to be tagged — turn source diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index b63e095e8..e8ad537cd 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -368,7 +368,9 @@ reported as names, 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. +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. `GetAppliedProperties` reads the element back-pointer, so it needs the control to be tagged — turn source diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 25d94b0bc..73852fad5 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -13,8 +13,9 @@ namespace Microsoft.UI.Reactor.Core.Diagnostics; /// "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. Empty for -/// function components and propless classes. +/// 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. diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 84ba3f495..8cfc6a692 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -63,11 +63,17 @@ public override async Task RunAsync() var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", counter is { Name: "Counter", Kind: "class", IsRoot: false }); - H.Check("CompInspect_ClassProps", - counter is not null + // NativeAOT/trimming may strip the props record's property metadata; Props then + // degrades to one "(members unavailable)" row. Either way the secret never leaks. + var fullProps = counter is not null && counter.Props.Count == 2 && counter.Props[0] is { Name: "Step", Type: "int", Value: "2", Redacted: false } - && counter.Props[1] is { Name: "AdminPassword", Value: "", Redacted: true }); + && counter.Props[1] is { Name: "AdminPassword", Value: "", Redacted: true }; + var opaqueProps = counter is { Props: [{ Name: "Props", Value: "CounterProps (members unavailable)" }] }; + H.Check("CompInspect_ClassProps", + counter is not null + && !counter.Props.Any(p => p.Value.Contains("hunter2", StringComparison.Ordinal)) + && (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 }]); From 0bdcf4cdedface1e219e61d0a72b9c58b977154b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 17:04:12 -0700 Subject: [PATCH 06/47] Diagnostics: host-control content ownership, overlap precedence, throwing props - ReactorHostControl attributes its Content only while it is Reactor's own rendered root or overlay wrapper (Content is public). - Document and pin the wrapper-wins precedence when a root renders a component directly. - A throwing props getter is reported as ''. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/templates/source-mapping.md.dt | 7 ++- docs/guide/source-mapping.md | 7 ++- .../Core/Diagnostics/DiagnosticText.cs | 14 +++++- .../ReactorDiagnostics.Components.cs | 11 ++++- .../Hosting/ReactorHostControl.Diagnostics.cs | 13 ++++-- .../Fixtures/ComponentInspectionFixtures.cs | 46 ++++++++++++++++++- .../ReactorDiagnosticsComponentsTests.cs | 8 +++- 7 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 6ffd60e6d..63ed6441c 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -295,7 +295,12 @@ 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. `GetAppliedProperties` reads +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 diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index e8ad537cd..b23eea53e 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -372,7 +372,12 @@ 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. `GetAppliedProperties` reads +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 diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 6356fb72d..cd63bb60d 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -300,8 +300,18 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul .Where(static p => p.Name != "EqualityContract" && p.GetIndexParameters().Length == 0 && p.CanRead); foreach (var property in readable) { - try { rows.Add((property.Name, property.PropertyType, property.GetValue(props))); } - catch (global::System.Reflection.TargetInvocationException) { } + object? value; + try + { + value = property.GetValue(props); + } + catch (global::System.Reflection.TargetInvocationException) + { + // A throwing getter 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(), which for a record would print every member, secrets included. diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 73852fad5..4683b17ac 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -88,7 +88,16 @@ public static partial class ReactorDiagnostics /// 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. + /// + /// 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) { diff --git a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs index 1288d0398..cf0ec723d 100644 --- a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs +++ b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs @@ -7,17 +7,22 @@ public sealed partial class ReactorHostControl { /// /// for this control. The root anchor - /// is the control itself, its current content (which is a dev-overlay wrapper while - /// one is installed), or the rendered root control. + /// 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; var control = _currentControl; + if (control is null) return null; + 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) - || ReferenceEquals(element, Content); - if (!isAnchor || control is null) return null; + || (contentIsOurs && ReferenceEquals(element, content)); + if (!isAnchor) return null; var component = _rootComponent; var funcContext = _funcContext; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 8cfc6a692..fd4b2baec 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -143,10 +143,30 @@ public override Element Render() } } + 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. + /// 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) { @@ -184,6 +204,22 @@ static bool IsRoot(ComponentSnapshot? s) => 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()); + 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 host without a ContentTarget installs its root as the window content. var window = H.Window; var previousContent = window.Content; @@ -220,6 +256,14 @@ await Harness.WaitFor(() => bare.CurrentControl is not null && ReferenceEquals(w && 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; + hostControl.Dispose(); H.Check("RootAnchor_DisposedHostControlNotDescribed", ReactorDiagnostics.DescribeComponent(hostControl) is null); } diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 2a61f251b..8c4fb1771 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -414,13 +414,17 @@ private sealed class FieldOnlyProps(string password) } [Fact] - public void Props_AThrowingGetterIsSkipped_NotFatal() + public void Props_AThrowingGetterIsReportedUnavailable_NotFatal() { var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new ThrowingProps()) }; var props = ComponentHandle.FromNode(node, () => true).Describe().Props; - Assert.Equal(new[] { new DiagnosticValue(0, "Good", "prop", "int", "1", false, false, false) }, props); + Assert.Equal(new[] + { + new DiagnosticValue(0, "Good", "prop", "int", "1", false, false, false), + new DiagnosticValue(1, "Bad", "prop", "int", "", false, false, false), + }, props); } [Fact] From 8e9865905f69bc8e2dc43fe561d69302dea16ab8 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 17:31:35 -0700 Subject: [PATCH 07/47] Diagnostics: refuse secret writes, undefined flag bits, state-held Ref; clear dialog tag - TrySetState refuses a parsed value its next snapshot would redact. - A [Flags] enum accepts only combinations of declared bits. - Hook kind keys on origin: UseState(new Ref()) is a state hook, not a ref hook; reactor.state keeps its historical 'useRef' naming. - A ContentDialog's diagnostics tag is cleared when ShowAsync completes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/templates/source-mapping.md.dt | 6 ++- docs/guide/source-mapping.md | 6 ++- .../Core/Diagnostics/DiagnosticText.cs | 28 +++++++++++++- .../ReactorDiagnostics.Components.cs | 7 ++++ src/Reactor/Core/RenderContext.cs | 23 ++++++++---- .../Core/V1Protocol/OverlayLifecycle.cs | 3 ++ .../Fixtures/ContentDialogTagFixtures.cs | 9 +++++ .../Diagnostics/DiagnosticTextTests.cs | 3 ++ .../ReactorDiagnosticsComponentsTests.cs | 37 +++++++++++++++++++ 9 files changed, 109 insertions(+), 13 deletions(-) diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 63ed6441c..86d5ceb0b 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -286,9 +286,11 @@ ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or `ConnectionString`; an object whose own text, or a string, names such a member with a value (`AccessToken=…`, a record with a `Password` property) — are reported as -`` and are never editable. +`` and are never editable; nor is a value written that would read back +redacted. `TrySetState` accepts numbers, single characters, `true`/`false`, enum member -names, dates, time spans, GUIDs, plain text and their nullables (`null` clears a +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 diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index b23eea53e..6dc335fc4 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -363,9 +363,11 @@ ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or `ConnectionString`; an object whose own text, or a string, names such a member with a value (`AccessToken=…`, a record with a `Password` property) — are reported as -`` and are never editable. +`` and are never editable; nor is a value written that would read back +redacted. `TrySetState` accepts numbers, single characters, `true`/`false`, enum member -names, dates, time spans, GUIDs, plain text and their nullables (`null` clears a +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 diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index cd63bb60d..23ad5d29b 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -206,6 +206,32 @@ private static string OwnText(object value, Func toString) } } + /// + /// 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; + } + + // A boxed enum unboxes to its underlying integral type. + private static ulong ToBits(object value) => Convert.GetTypeCode(value) switch + { + TypeCode.SByte => (byte)(sbyte)value, + TypeCode.Byte => (byte)value, + TypeCode.Int16 => (ushort)(short)value, + TypeCode.UInt16 => (ushort)value, + TypeCode.Int32 => (uint)(int)value, + TypeCode.UInt32 => (uint)value, + TypeCode.Int64 => unchecked((ulong)(long)value), + TypeCode.UInt64 => (ulong)value, + _ => 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. @@ -238,7 +264,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul if (target.IsEnum) { if (Enum.TryParse(target, text, ignoreCase: true, out var parsed) && - (Enum.IsDefined(target, parsed!) || target.IsDefined(typeof(FlagsAttribute), false))) + (Enum.IsDefined(target, parsed!) || (target.IsDefined(typeof(FlagsAttribute), false) && OnlyDefinedFlags(target, parsed!)))) { value = parsed; return true; diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 4683b17ac..870008dc4 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -406,6 +406,13 @@ internal bool TrySetState(int hookIndex, string text, out string? error) error = parseError; return false; } + // The same rule that redacts a read refuses a write, so a value the next snapshot would + // hide (a string such as "AccessToken=…") is never written from diagnostics either. + if (DiagnosticText.Format("", valueType, parsed).Redacted) + { + error = $"the value for hook {hookIndex} of <{Name}> looks like a secret; it is not written from diagnostics"; + return false; + } if (!_context.TrySetHookValueForDiagnostics(hookIndex, parsed)) { error = $"hook {hookIndex} of <{Name}> refused the value"; diff --git a/src/Reactor/Core/RenderContext.cs b/src/Reactor/Core/RenderContext.cs index 28695b445..05f734884 100644 --- a/src/Reactor/Core/RenderContext.cs +++ b/src/Reactor/Core/RenderContext.cs @@ -2570,8 +2570,11 @@ internal IReadOnlyList SnapshotHooks() var kind = h.DiagnosticKind; string hookName = kind switch { - HookKind.State or HookKind.Reducer => "useState", - HookKind.Ref => "useRef", + // 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", HookKind.Memo => "useMemo", HookKind.Persisted => "usePersisted", HookKind.Effect => "useEffect", @@ -2681,14 +2684,18 @@ private protected static bool TryUnbox(object? value, out TValue result) 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 => - typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Ref<>) - ? HookKind.Ref - : SetterKind is SetterKindReducer or SetterKindDispatch - ? HookKind.Reducer + 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 => Value is IRefValue r ? r.ValueType : typeof(T); + 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 @@ -2706,7 +2713,7 @@ internal override object? DiagnosticValue object? v; if (!ThreadSafe) v = Value; else lock (Lock!) v = Value; - return v is IRefValue r ? r.CurrentBoxed : v; + return DiagnosticKind == HookKind.Ref && v is IRefValue r ? r.CurrentBoxed : v; } } // A Ref is mutated through Ref.Current and never schedules a render, diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index 87496f129..82ddbfb0f 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -270,6 +270,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/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs index bf8dda34a..4b6a4ba50 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs @@ -53,6 +53,15 @@ public override async Task RunAsync() 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 { diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 326b02cda..b2b5d9cbc 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -260,6 +260,7 @@ public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() ["hello world", typeof(string), "hello world"], ["friday", typeof(DayOfWeek), DayOfWeek.Friday], ["Read, Write", typeof(Access), Access.Read | Access.Write], + ["3", typeof(Access), Access.Read | Access.Write], ["01:02:03", typeof(TimeSpan), new TimeSpan(1, 2, 3)], ["2026-10-05T12:00:00Z", typeof(DateTime), new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc)], ["2026-10-05T12:00:00+02:00", typeof(DateTimeOffset), new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.FromHours(2))], @@ -285,6 +286,8 @@ public void TryParse_ParsesTextTypes(string text, Type type, object? expected) [InlineData("xy", typeof(char), "'xy' is not a single character")] [InlineData("Funday", typeof(DayOfWeek), "use one of Sunday, Monday")] [InlineData("8", typeof(DayOfWeek), "'8' is not a DayOfWeek")] + [InlineData("8", typeof(Access), "'8' is not a Access")] + [InlineData("Read, 4", typeof(Access), "is not a Access; use one of None, Read, Write")] [InlineData("x", typeof(List), "a List value cannot be typed as text")] [InlineData("null", typeof(List), "a List value cannot be typed as text")] [InlineData("null", typeof(object), "a object value cannot be typed as text")] diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 8c4fb1771..f49415860 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -218,6 +218,43 @@ private static void AssertRefused(ComponentHandle handle, int index, string text Assert.Contains(reason, error); } + [Fact] + public void TrySetState_RefusesAValueItsNextSnapshotWouldRedact() + { + var r = new Renderer(); + r.Begin(); + r.Context.UseState("plain"); + var handle = ForContext(r.Context); + + AssertRefused(handle, 0, "AccessToken=abc123", "looks like a secret; it is not written from diagnostics"); + + Assert.Equal("\"plain\"", handle.Describe().State[0].Value); + Assert.Equal(0, r.Rerenders); + // An ordinary value on the same hook is still written. + Assert.True(handle.TrySetState(0, "hello", out var error), error); + Assert.Equal("\"hello\"", handle.Describe().State[0].Value); + } + + [Fact] + public void State_HoldingARef_IsAStateHook_NotARefHook() + { + var r = new Renderer(); + r.Begin(); + var box = new Ref(4); + r.Context.UseState(box); + r.Context.UseRef(5); + var handle = ForContext(r.Context); + + var hooks = handle.Describe().State; + Assert.Equal(("state", "Ref", false), (hooks[0].Kind, hooks[0].Type, hooks[0].Editable)); + Assert.Equal(("ref", "int", "5"), (hooks[1].Kind, hooks[1].Type, hooks[1].Value)); + // Refused for its type (a Ref cannot be typed as text), not as though it came from UseRef. + AssertRefused(handle, 0, "1", "a Ref value cannot be typed as text"); + AssertRefused(handle, 1, "1", "is a ref hook"); + // reactor.state keeps naming a Ref cell "useRef", as it always has. + Assert.Equal(new[] { "useRef", "useRef" }, r.Context.SnapshotHooks().Select(s => s.Hook)); + } + [Fact] public void Unmounted_RefusesWritesAndRerender() { From 8ffaf29e708374ee10bc6a7a002e52f2f35b32e7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 17:47:37 -0700 Subject: [PATCH 08/47] Diagnostics: redact quoted secret labels; refresh UseContext identity per render - The labelled-secret pattern allows quotes around the member name and value, so JSON-shaped text is redacted. - UseContext refreshes the slot's Context each render (like LastValue), so a slot switched to another context is described, redacted and change-detected against the context it last read. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 6 +++--- src/Reactor/Core/RenderContext.cs | 4 ++++ .../Diagnostics/DiagnosticTextTests.cs | 5 ++++- .../ReactorDiagnosticsComponentsTests.cs | 21 +++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 23ad5d29b..0676d7d90 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -123,10 +123,10 @@ internal static bool IsSecretName(string? name) // A member of a secret name followed by a value, inside an object's own text or a string: // "Password = …" in a record's compiler-generated ToString(), "ApiSecret: …" in a hand-written - // one, "AccessToken=…" in a connection-style string. A bare label ("Password:") has no value - // after the separator and does not match. + // one, "AccessToken=…" in a connection-style string, "\"Password\":\"…\"" in JSON. A bare label + // ("Password:") or an empty quoted value has nothing after the separator and does not match. private static readonly global::System.Text.RegularExpressions.Regex s_secretMemberInText = new( - @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)\s*[=:]\s*[^\s,;}\]]", + @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*[""']?[^\s,;}\]""']", global::System.Text.RegularExpressions.RegexOptions.IgnoreCase | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant); /// diff --git a/src/Reactor/Core/RenderContext.cs b/src/Reactor/Core/RenderContext.cs index 05f734884..88f2e10a9 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; diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index b2b5d9cbc..43c776993 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -210,6 +210,8 @@ public void Format_RedactsHandWrittenTextNamingASecretMember(string text) [InlineData("AccessToken=hunter2")] [InlineData("Server=db;User Id=me;Password=hunter2;")] [InlineData("apiKey: hunter2")] + [InlineData("{\"Password\":\"hunter2\"}")] + [InlineData("{ 'accessToken': 'hunter2' }")] public void Format_RedactsAStringCarryingALabelledSecret(string text) { var (shown, redacted) = DiagnosticText.Format("", typeof(string), text); @@ -242,8 +244,9 @@ public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() Assert.Equal(("", true), DiagnosticText.FormatPlain("ToolTip", "AccessToken=hunter2")); Assert.Equal(("", true), DiagnosticText.FormatPlain("", new FormattableSecret())); Assert.Equal(("", true), DiagnosticText.FormatPlain("", new HandWritten("Password = hunter2"))); - // A bare label is ordinary UI text. + // A bare label is ordinary UI text, as is a JSON member with an empty value. Assert.Equal(("Password:", false), DiagnosticText.FormatPlain("ToolTip", "Password:")); + Assert.Equal(("{\"Password\":\"\"}", false), DiagnosticText.FormatPlain("ToolTip", "{\"Password\":\"\"}")); } // ── Parsing ───────────────────────────────────────────────────── diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index f49415860..3ddb6a174 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -350,6 +350,27 @@ public void Contexts_SecretNamedContextIsRedacted_InContextsAndState() Assert.DoesNotContain("s3cr3t", snapshot.Contexts.Single().Value + snapshot.State.Single().Value); } + [Fact] + public void Contexts_ASlotSwitchedToAnotherContext_IsDescribedByTheOneLastRead() + { + var theme = new Context("light", "Theme"); + var apiSecret = new Context("", "ApiSecret"); + var r = new Renderer(); + r.Scope.Push(new Dictionary { [theme] = "dark", [apiSecret] = "s3cr3t" }); + r.Begin(); + r.Context.UseContext(theme); + Assert.Equal("Theme", ForContext(r.Context).Describe().Contexts.Single().Name); + + r.Begin(); + r.Context.UseContext(apiSecret); + + var snapshot = ForContext(r.Context).Describe(); + Assert.Equal("ApiSecret", snapshot.Contexts.Single().Name); + Assert.True(snapshot.Contexts.Single().Redacted); + Assert.True(snapshot.State.Single().Redacted); + Assert.DoesNotContain("s3cr3t", snapshot.Contexts.Single().Value + snapshot.State.Single().Value); + } + private sealed class EqualsThrows { public override bool Equals(object? obj) => throw new InvalidOperationException("boom"); From 0b6228d3fd9d6534a329fd06e349b2cd8c22575f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 18:02:35 -0700 Subject: [PATCH 09/47] Diagnostics: redact secret-typed props whole; list only public getters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 8 +++-- .../ReactorDiagnosticsComponentsTests.cs | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 0676d7d90..9f725453d 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -318,12 +318,16 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul { if (props is null) return global::System.Array.Empty<(string, Type, object?)>(); var type = props.GetType(); - if (type.IsPrimitive || props is string or decimal or Enum || IsListLike(props)) + // A secret-bearing props type stays one row, so Format redacts it whole instead of the + // members of, say, a SessionToken(string Value) being listed one by one. + if (type.IsPrimitive || props is string or decimal or Enum || IsListLike(props) + || HoldsSecretType(declared) || HoldsSecretType(type)) 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.CanRead); + .Where(static p => p.Name != "EqualityContract" && p.GetIndexParameters().Length == 0 && p.GetMethod is { IsPublic: true }); foreach (var property in readable) { object? value; diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 3ddb6a174..8f25b1803 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -471,6 +471,35 @@ private sealed class FieldOnlyProps(string password) public override string ToString() => $"pw={AdminPassword}"; } + [Fact] + public void Props_OfASecretBearingType_AreOneRedactedRow_NotListedMemberByMember() + { + var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new SessionToken("s3cr3t")) }; + + var row = Assert.Single(ComponentHandle.FromNode(node, () => true).Describe().Props); + + Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); + } + + [Fact] + public void Props_SkipAPropertyWhoseGetterIsNotPublic() + { + var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new PrivateGetterProps()) }; + + var props = ComponentHandle.FromNode(node, () => true).Describe().Props; + + Assert.Equal(new[] { new DiagnosticValue(0, "Visible", "prop", "string", "\"v\"", false, false, false) }, props); + } + + private sealed record SessionToken(string Value); + + private sealed class PrivateGetterProps + { + public string Visible => "v"; + public string Session { private get; set; } = "hidden"; + public override string ToString() => Session; + } + [Fact] public void Props_AThrowingGetterIsReportedUnavailable_NotFatal() { From db8253fc95ff17d1b7ada4a2ce500f5a14071bc3 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 18:50:35 -0700 Subject: [PATCH 10/47] Diagnostics: authored reference-list order, attribution-only dialog tag, nint not editable - GetReferenceEdges projects the list as authored (order and repeats), not the deduplicated subscription set. - A side-mounted ContentDialog's tag is attribution-only: its modifiers went to the placeholder, so applied properties and reference edges report nothing for the dialog. - nint/nuint are not advertised as editable (no text parse exists). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/templates/source-mapping.md.dt | 5 +++- docs/guide/source-mapping.md | 5 +++- .../Core/Diagnostics/DiagnosticText.cs | 3 ++- .../ReactorDiagnostics.Components.cs | 2 ++ .../ReactorDiagnostics.References.cs | 16 +++++++++---- src/Reactor/Core/Reconciler.cs | 2 ++ .../Core/V1Protocol/OverlayLifecycle.cs | 9 +++++++ src/Reactor/Core/V1Protocol/ReferenceEdge.cs | 5 ++++ .../Fixtures/ContentDialogTagFixtures.cs | 10 +++++++- .../Diagnostics/DiagnosticTextTests.cs | 2 ++ .../ReactorDiagnosticsReferenceEdgesTests.cs | 24 +++++++++++++++++-- 11 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 86d5ceb0b..d46c86dcb 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -313,7 +313,10 @@ control (`Button("Save")`) when you set none: it is reported as 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. +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 diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index 6dc335fc4..356001ae1 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -390,7 +390,10 @@ control (`Button("Save")`) when you set none: it is reported as 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. +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 diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 9f725453d..917a35548 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -75,7 +75,8 @@ internal static bool IsEditable(Type? type) { if (type is null) return false; type = Nullable.GetUnderlyingType(type) ?? type; - return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || + // 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); } diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 870008dc4..ead8e6bc6 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -180,6 +180,8 @@ public static IReadOnlyList GetAppliedProperties(UIElement cont var tag = Reconciler.GetElementTag(control); if (tag is null) return global::System.Array.Empty(); var source = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag; + if (Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, source)) + return global::System.Array.Empty(); var applied = new List(); if (source.Modifiers is { } modifiers) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index f703be02c..52e8b3637 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -65,8 +65,9 @@ public static IReadOnlyList GetReferenceEdges(UIElement c UIElement? resolvedLabeledBy = null; if (pendingLabeledBy is null && Reconciler.GetElementTag(control) is { } tag - && (Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag) - .Modifiers?.Accessibility?.LabeledBy is { } id) + && (Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag) is var source + && !Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, source) + && source.Modifiers?.Accessibility?.LabeledBy is { } id) { authoredLabeledBy = id; if (Microsoft.UI.Xaml.Automation.AutomationProperties.GetLabeledBy(control) is { } live @@ -104,8 +105,15 @@ internal static IReadOnlyList Describe( foreach (var (slot, listEdge) in bag.ListEdges) { - for (int i = 0; i < listEdge.Cells.Count; i++) - (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, listEdge.Cells[i]))); + // The authored list, not the deduplicated subscription set, so order and repeats + // match what the author wrote; Index is the authored position. + var authored = listEdge.Authored; + if (authored is null) continue; + for (int i = 0; i < authored.Count; i++) + { + if (authored[i] is not { } cell) continue; + (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, cell))); + } } } diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 63e135438..036be05b8 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -1484,6 +1484,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(); @@ -1540,6 +1541,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; diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index 82ddbfb0f..71fc79812 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -231,6 +231,15 @@ private static void TagDialogForDiagnostics(WinUI.ContentDialog dialog, ContentD 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 diff --git a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs index 57873f534..44ea1ebbe 100644 --- a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs +++ b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs @@ -24,6 +24,11 @@ internal sealed class ReferenceEdge internal sealed class ReferenceListEdge { public readonly List Cells = new(); + /// + /// The list exactly as last authored (order, duplicates and nulls kept). + /// is deduplicated subscription bookkeeping; diagnostics project this instead. + /// + public IReadOnlyList? Authored; public Action? Handler; public Action? Recompute; /// diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs index 4b6a4ba50..1639dd53a 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs @@ -29,7 +29,8 @@ public override async Task RunAsync() var (title, setTitle) = ctx.UseState("TagDialog"); return VStack( TextBlock("anchor"), - ContentDialog(title, Button("Retitle", () => setTitle("TagDialog2")), "OK") with { IsOpen = true }); + (ContentDialog(title, Button("Retitle", () => setTitle("TagDialog2")), "OK") with { IsOpen = true }) + .Width(400).LabeledBy("tag-dialog-label")); }); dialog = await ContentDialogProbe.WaitForOpen(H, "TagDialog"); @@ -39,6 +40,13 @@ public override async Task RunAsync() 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")); diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 43c776993..4edc5a99a 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -308,6 +308,8 @@ public void TryParse_RefusesWithAReason(string text, Type type, string reason) [InlineData(typeof(DayOfWeek), true)] [InlineData(typeof(string), true)] [InlineData(typeof(object), false)] + [InlineData(typeof(nint), false)] + [InlineData(typeof(nuint?), false)] [InlineData(typeof(List), false)] public void IsEditable(Type type, bool expected) => Assert.Equal(expected, DiagnosticText.IsEditable(type)); diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs index a9bc0be10..f3654ab87 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs @@ -22,12 +22,32 @@ private static void AddScalar(ReferenceEdgeBag bag, int slot, ElementRef? cell) => bag.Edges[slot] = new ReferenceEdge { Cell = cell }; private static void AddList(ReferenceEdgeBag bag, int slot, params ElementRef[] cells) + => AddList(bag, slot, cells, cells.Distinct().ToArray()); + + // `cells` as authored; `subscribed` as WireReferenceListEdge leaves its deduplicated + // bookkeeping (which keeps retained cells in their earlier order). + private static void AddList(ReferenceEdgeBag bag, int slot, ElementRef[] cells, ElementRef[] subscribed) { - var edge = new ReferenceListEdge(); - edge.Cells.AddRange(cells); + var edge = new ReferenceListEdge { Authored = cells }; + edge.Cells.AddRange(subscribed); bag.ListEdges[slot] = edge; } + [Fact] + public void ListEdge_ReportsTheAuthoredSequence_NotTheSubscriptionSet() + { + var a = new ElementRef(new ElementRef()); + var b = new ElementRef(new ElementRef()); + var bag = Bag(); + // Re-authored from [a, b] to [b, a, a]: the subscription set still reads [a, b]. + AddList(bag, ReferenceSlots.ModifierRef_FlowsTo, cells: [b, a, a], subscribed: [a, b]); + + var edges = ReferenceEdgeMap.Describe(bag, null, null, null); + + Assert.Equal(new[] { ("TextBlock", 0), ("Button", 1), ("Button", 2) }, + edges.Select(e => (e.ExpectedTargetTypeName!, e.Index))); + } + [Fact] public void PendingModifierRef_IsReportedUnresolved() { From 79375065485183c2973031e4ff7a30cf57ac460b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 19:06:33 -0700 Subject: [PATCH 11/47] Diagnostics: report Scale/Rotation/Translation/CenterPoint as applied properties The reconciler writes these composition-backed UIElement facades, so GetAppliedProperties now lists them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/AppliedModifierMap.cs | 10 ++++++++-- .../ReactorDiagnosticsComponentsTests.cs | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index affb74fe2..738c8e8b6 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -16,8 +16,9 @@ namespace Microsoft.UI.Reactor.Core.Diagnostics; /// 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), and Scale/Rotation/Translation/ -/// CenterPoint (composition-backed facades, not DPs). +/// 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. /// @@ -94,6 +95,11 @@ internal static class AppliedModifierMap 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. diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 8f25b1803..b9beaab6b 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -734,6 +734,22 @@ public void AppliedModifierMap_AttachedFlyoutFollowsResolveFlyoutSlot() Assert.Equal("FlyoutBase.AttachedFlyout", AppliedModifierMap.Describe(m, typeof(WinUI.TextBlock)).Single().Property); } + [Fact] + public void AppliedModifierMap_TransformModifiersMapToTheirUIElementFacades() + { + var m = new ElementModifiers + { + Scale = new global::System.Numerics.Vector3(2, 2, 1), + Rotation = 45f, + Translation = new global::System.Numerics.Vector3(1, 0, 0), + CenterPoint = new global::System.Numerics.Vector3(5, 5, 0), + }; + + Assert.Equal( + new[] { ("Scale", "UIElement.Scale"), ("Rotation", "UIElement.Rotation"), ("Translation", "UIElement.Translation"), ("CenterPoint", "UIElement.CenterPoint") }, + AppliedModifierMap.Describe(m, typeof(WinUI.TextBlock)).Select(p => (p.Modifier, p.Property))); + } + [Fact] public void AppliedModifierMap_EmptyModifiers_ReportsNothing() { @@ -748,7 +764,6 @@ public void AppliedModifierMap_EmptyModifiers_ReportsNothing() private static readonly HashSet NotPropertyWrites = new(StringComparer.Ordinal) { "Layout", "Visual", "Accessibility", - "Scale", "Rotation", "Translation", "CenterPoint", "OnMountAction", "OnUnmountAction", "Pan", "Pinch", "Rotate", "LongPress", "DragSource", "DropTarget", "Ref", "Backdrop", From 4314868adbf8f7a006501b01eded8996f589a2b9 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 19:27:41 -0700 Subject: [PATCH 12/47] Diagnostics: AutomationId LabeledBy resolves only to a loaded target; clarify list Index - A resolved AutomationId LabeledBy edge requires the live target to be loaded in the control's XamlRoot, so a label that unmounted is no longer reported resolved (new selftest, mutation-checked). - Index counts references: null list entries carry none and are dropped before wiring; docs now say so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ReactorDiagnostics.References.cs | 15 ++--- src/Reactor/Core/V1Protocol/ReferenceEdge.cs | 7 ++- .../Fixtures/ReferenceEdgeFixtures.cs | 56 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index 52e8b3637..e490b76f2 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -14,7 +14,8 @@ namespace Microsoft.UI.Reactor.Core.Diagnostics; /// "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). +/// 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 @@ -70,7 +71,10 @@ public static IReadOnlyList GetReferenceEdges(UIElement c && source.Modifiers?.Accessibility?.LabeledBy is { } id) { authoredLabeledBy = id; - if (Microsoft.UI.Xaml.Automation.AutomationProperties.GetLabeledBy(control) is { } live + // 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; } @@ -106,14 +110,11 @@ internal static IReadOnlyList Describe( 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 authored position. + // 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++) - { - if (authored[i] is not { } cell) continue; - (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, cell))); - } + (result ??= new()).Add((slot, ForCell(slot, isList: true, index: i, authored[i]))); } } diff --git a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs index 44ea1ebbe..bbbce4cfe 100644 --- a/src/Reactor/Core/V1Protocol/ReferenceEdge.cs +++ b/src/Reactor/Core/V1Protocol/ReferenceEdge.cs @@ -25,10 +25,11 @@ internal sealed class ReferenceListEdge { public readonly List Cells = new(); /// - /// The list exactly as last authored (order, duplicates and nulls kept). - /// is deduplicated subscription bookkeeping; diagnostics project this instead. + /// 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 IReadOnlyList? Authored; public Action? Handler; public Action? Recompute; /// diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs index b2cdacb98..805b64538 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -88,4 +88,60 @@ public override async Task RunAsync() } } } + + /// + /// A resolved AutomationId .LabeledBy("id") whose label then unmounts: the live + /// LabeledBy property still holds the detached label, but the edge must no longer be + /// reported as resolved to it. + /// + internal class ResolvedAutomationIdThenUnmounted(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { +#if REACTOR_SOURCEMAP + var previous = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled; + Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled = true; + try + { + Action? setShow = null; + var host = H.CreateHost(); + host.Mount(ctx => + { + var (show, set) = ctx.UseState(true); + setShow = set; + return VStack( + show ? TextBlock("refedge-label-text").AutomationId("refedge-live-label") : Button("refedge-gone"), + TextBox(placeholderText: "refedge-labelled-live").LabeledBy("refedge-live-label")); + }); + await Harness.Render(); + await Harness.Render(50); + + var box = H.FindControl(t => t.PlaceholderText == "refedge-labelled-live"); + var label = H.FindControl(t => t.Text == "refedge-label-text"); + H.Check("RefEdges_LiveLabelMounted", box is not null && label is not null); + if (box is null || label is null || setShow is null) return; + + var before = ReactorDiagnostics.GetReferenceEdges(box).SingleOrDefault(e => e.Property == "LabeledBy"); + H.Check("RefEdges_AutomationIdResolved", + before is { TargetAutomationId: "refedge-live-label", IsResolved: true } && ReferenceEquals(before.Target, label)); + + setShow(false); + await Harness.WaitFor(() => !label.IsLoaded, maxPasses: 16, perPassMs: 10); + H.Check("RefEdges_LabelUnmounted", !label.IsLoaded); + + var after = ReactorDiagnostics.GetReferenceEdges(box).SingleOrDefault(e => e.Property == "LabeledBy"); + H.Check("RefEdges_UnmountedLabelNotResolved", + after is { TargetAutomationId: "refedge-live-label", IsResolved: false, Target: null }); + } + finally + { + Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.Enabled = previous; + } +#else + H.Skip("RefEdges_UnmountedLabelNotResolved", + "assembly built without REACTOR_SOURCEMAP (Release) - the control is not tagged, so the resolved AutomationId form is not recoverable"); + await Task.CompletedTask; +#endif + } + } } \ No newline at end of file diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 107c7d79c..00c6245a1 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -1782,6 +1782,7 @@ internal static class SelfTestFixtureRegistry "ReferenceOverlay_CycleDiagnostic", "Diagnostics_ReferenceEdges", "Diagnostics_ReferenceEdgesPendingId", + "Diagnostics_ReferenceEdgesUnmountedLabel", // Spec 057 §10 — first-party TeachingTip.Target reference proof. "TeachingTip_TargetReferenceResolvesBothMountOrders", "TeachingTip_SurfaceParity", @@ -3743,6 +3744,7 @@ internal static string[] StaleTierDeclarations() => "ReferenceOverlay_CycleDiagnostic" => new ReferenceOverlaySelfHostFixtures.CycleDiagnostic(harness), "Diagnostics_ReferenceEdges" => new ReferenceEdgeFixtures.PendingThenResolved(harness), "Diagnostics_ReferenceEdgesPendingId" => new ReferenceEdgeFixtures.PendingAutomationIdSurvivesLoaded(harness), + "Diagnostics_ReferenceEdgesUnmountedLabel" => new ReferenceEdgeFixtures.ResolvedAutomationIdThenUnmounted(harness), "TeachingTip_TargetReferenceResolvesBothMountOrders" => new TeachingTipTargetFixtures.TargetReferenceResolvesBothMountOrders(harness), "TeachingTip_SurfaceParity" => new RefNodeSurfaceParityFixtures.Execution(harness), "RealRef_TeachingTipTarget_CrossSubtree" => new RealRefTortureFixtures.TeachingTipTargetCrossSubtree(harness), From f662cf53a08302586767ba904e982b1bfff75605 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 19:44:48 -0700 Subject: [PATCH 13/47] Diagnostics: clear pending LabeledBy on unmount; empty explicit name keeps the default - TeardownReferenceEdges clears PendingLabeledBy on every path, so a retained unpooled control stops reporting a pending edge after unmount (selftest, mutation-checked). - An empty .AutomationName("") no longer suppresses the reported DefaultAutomationName, matching the apply path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/AppliedModifierMap.cs | 5 +++-- src/Reactor/Core/Reconciler.cs | 8 ++++++-- .../SelfTest/Fixtures/ReferenceEdgeFixtures.cs | 13 +++++++++++++ .../ReactorDiagnosticsDefaultAutomationNameTests.cs | 10 ++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index 738c8e8b6..fe32e1e97 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -175,8 +175,9 @@ internal static IReadOnlyList Describe(ElementModifiers modifie /// internal static AppliedModifier? DescribeDefaultAutomationName(Element source, string? liveName) { - // An explicit .AutomationName(...) is reported as itself and suppresses the default. - if (source.Modifiers?.AutomationName is not null) return null; + // 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; diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 036be05b8..914c946c1 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -1518,8 +1518,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) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs index 805b64538..36d1d928d 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -81,6 +81,19 @@ public override async Task RunAsync() var edge = ReactorDiagnostics.GetReferenceEdges(box).SingleOrDefault(e => e.Property == "LabeledBy"); H.Check("RefEdges_PendingAutomationIdAfterLoaded", edge is { TargetAutomationId: "refedge-missing-label", IsResolved: false, Target: null }); + + // Unmount drops the pending request: a retained, unpooled control (a CheckBox, so + // the pool's own reset is not what clears it) reports no edge afterwards. + host.Mount(ctx => VStack(CheckBox(label: "refedge-unmount-check").LabeledBy("refedge-missing-label2"))); + await Harness.Render(); + await Harness.Render(50); + var check = H.FindControl(c => c.Content as string == "refedge-unmount-check"); + H.Check("RefEdges_PendingBeforeUnmount", + check is not null && ReactorDiagnostics.GetReferenceEdges(check).Any(e => e.TargetAutomationId == "refedge-missing-label2")); + if (check is null) return; + host.Mount(ctx => TextBlock("refedge-replaced")); + await Harness.WaitFor(() => H.FindControl(t => t.Text == "refedge-replaced") is not null, maxPasses: 16, perPassMs: 10); + H.Check("RefEdges_NoPendingAfterUnmount", ReactorDiagnostics.GetReferenceEdges(check).Count == 0); } finally { diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs index 895797688..853bb21dd 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs @@ -41,6 +41,16 @@ public void DefaultAutomationName_ExplicitModifierSuppressesTheDefault() Assert.Null(AppliedModifierMap.DescribeDefaultAutomationName(named, liveName: "Save")); } + [Fact] + public void DefaultAutomationName_AnEmptyExplicitNameDoesNotSuppressTheDefault() + { + // The apply path treats an empty name as absent and writes the caption over it. + var emptyNamed = Button("Save").AutomationName(""); + + Assert.Equal(new AppliedModifier("DefaultAutomationName", "AutomationProperties.Name", "Save"), + AppliedModifierMap.DescribeDefaultAutomationName(emptyNamed, liveName: "Save")); + } + [Fact] public void DefaultAutomationName_UsesTheSameTruncationAsTheApplyPath() { From 10e7c4ac1d2237751ef703348f02fb6a667e090b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 20:04:40 -0700 Subject: [PATCH 14/47] Diagnostics: merge decorator-chain modifiers; robust flags bits; precise cost note - GetAppliedProperties merges the whole decorator chain in application order (target first, decorators outward; outer wins), and a decorator's explicit name suppresses the target's default. - A resolved AutomationId LabeledBy is found on any decorator level. - Flags bit checks read through IConvertible instead of unboxing. - CHANGELOG states the two render-path reference stores precisely. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 ++-- .../Core/Diagnostics/AppliedModifierMap.cs | 31 ++++++++++++++++++ .../Core/Diagnostics/DiagnosticText.cs | 25 ++++++++------- .../ReactorDiagnostics.Components.cs | 19 ++++------- .../ReactorDiagnostics.References.cs | 18 +++++++++-- src/Reactor/Diagnostics/ReactorSourceMap.cs | 20 ++++++++++++ .../ReactorDiagnosticsComponentsTests.cs | 32 +++++++++++++++++++ .../ReactorDiagnosticsReferenceEdgesTests.cs | 13 ++++++++ 8 files changed, 134 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ed84fab..510316c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,10 @@ Conventions for contributors: `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 — with secrets - redacted; a resolved reference edge carries its mounted target control. On-demand - reads only, - UI-thread-affine, no render-path cost. With source mapping on, an open + redacted; a resolved reference edge carries its mounted target control. Reads + are on demand and UI-thread-affine. The render path gains only two reference + stores: `UseContext` records the context it read, and reference-list wiring + keeps the authored list. 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). diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index fe32e1e97..34dc1fc49 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -163,6 +163,37 @@ internal static IReadOnlyList Describe(ElementModifiers modifie /// Modifier name reported for the caption-derived default name. internal const string DefaultAutomationNameModifier = "DefaultAutomationName"; + /// + /// 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 no level set a non-empty explicit name. + /// + internal static IReadOnlyList DescribeChain(IReadOnlyList chain, Type controlType, string? liveName) + { + var merged = new List(); + var byProperty = new Dictionary(StringComparer.Ordinal); + bool explicitName = false; + for (int level = chain.Count - 1; level >= 0; level--) + { + if (chain[level].Modifiers is not { } modifiers) continue; + explicitName |= modifiers.AutomationName is { Length: > 0 }; + foreach (var m in Describe(modifiers, controlType)) + { + if (byProperty.TryGetValue(m.Property, out var at)) merged[at] = m; + else + { + byProperty[m.Property] = merged.Count; + merged.Add(m); + } + } + } + + if (!explicitName && 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 diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 917a35548..1f0805973 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -219,19 +219,20 @@ private static bool OnlyDefinedFlags(Type enumType, object value) return (ToBits(value) & ~mask) == 0; } - // A boxed enum unboxes to its underlying integral type. - private static ulong ToBits(object value) => Convert.GetTypeCode(value) switch + // 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) { - TypeCode.SByte => (byte)(sbyte)value, - TypeCode.Byte => (byte)value, - TypeCode.Int16 => (ushort)(short)value, - TypeCode.UInt16 => (ushort)value, - TypeCode.Int32 => (uint)(int)value, - TypeCode.UInt32 => (uint)value, - TypeCode.Int64 => unchecked((ulong)(long)value), - TypeCode.UInt64 => (ulong)value, - _ => ulong.MaxValue, - }; + 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 diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index ead8e6bc6..8436f8cdc 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -179,21 +179,14 @@ public static IReadOnlyList GetAppliedProperties(UIElement cont EnsureUIThread(); var tag = Reconciler.GetElementTag(control); if (tag is null) return global::System.Array.Empty(); - var source = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag; - if (Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, source)) + 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 applied = new List(); - if (source.Modifiers is { } modifiers) - { - foreach (var m in AppliedModifierMap.Describe(modifiers, control.GetType())) - applied.Add(ToText(m)); - } - - if (AppliedModifierMap.DescribeDefaultAutomationName( - source, Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(control)) is { } defaultName) - applied.Add(ToText(defaultName)); - + 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; } diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index e490b76f2..b4e48da6c 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -66,9 +66,9 @@ public static IReadOnlyList GetReferenceEdges(UIElement c UIElement? resolvedLabeledBy = null; if (pendingLabeledBy is null && Reconciler.GetElementTag(control) is { } tag - && (Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.UnwrapDecorators(tag) ?? tag) is var source - && !Microsoft.UI.Reactor.Core.V1Protocol.OverlayLifecycle.IsAttributionOnlyTag(control, source) - && source.Modifiers?.Accessibility?.LabeledBy is { } id) + && 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 @@ -90,6 +90,18 @@ public static IReadOnlyList GetReferenceEdges(UIElement c /// 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, diff --git a/src/Reactor/Diagnostics/ReactorSourceMap.cs b/src/Reactor/Diagnostics/ReactorSourceMap.cs index 7e3738ed7..f4e444396 100644 --- a/src/Reactor/Diagnostics/ReactorSourceMap.cs +++ b/src/Reactor/Diagnostics/ReactorSourceMap.cs @@ -153,6 +153,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/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index b9beaab6b..6f8ee35b8 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Reactor.Core.Diagnostics; using Microsoft.UI.Xaml; using Xunit; +using static Microsoft.UI.Reactor.Factories; using WinUI = Microsoft.UI.Xaml.Controls; namespace Microsoft.UI.Reactor.Tests.Diagnostics; @@ -750,6 +751,37 @@ public void AppliedModifierMap_TransformModifiersMapToTheirUIElementFacades() AppliedModifierMap.Describe(m, typeof(WinUI.TextBlock)).Select(p => (p.Modifier, p.Property))); } + [Fact] + public void DescribeChain_MergesDecoratorModifiersInApplicationOrder_OuterWins() + { + var inner = Button("Go").Width(10).AutomationName("go"); + var outer = Flyout(inner, TextBlock("menu")).Width(20).Margin(2); + + var chain = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(outer); + var merged = AppliedModifierMap.DescribeChain(chain, typeof(WinUI.Button), liveName: "go"); + + Assert.Equal(2, chain.Count); + Assert.Contains(new AppliedModifier("Width", "FrameworkElement.Width", 20d), merged); + Assert.Single(merged, p => p.Property == "FrameworkElement.Width"); + Assert.Contains(new AppliedModifier("AutomationName", "AutomationProperties.Name", "go"), merged); + Assert.Contains(merged, p => p.Property == "FrameworkElement.Margin"); + Assert.DoesNotContain(merged, p => p.Modifier == "DefaultAutomationName"); + } + + [Fact] + public void DescribeChain_ADecoratorsExplicitNameSuppressesTheTargetsDefault() + { + var withOuterName = Flyout(Button("Save"), TextBlock("menu")).AutomationName("Save"); + var withoutName = Flyout(Button("Save"), TextBlock("menu")); + + Assert.DoesNotContain( + AppliedModifierMap.DescribeChain(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(withOuterName), typeof(WinUI.Button), "Save"), + p => p.Modifier == "DefaultAutomationName"); + Assert.Contains( + AppliedModifierMap.DescribeChain(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(withoutName), typeof(WinUI.Button), "Save"), + p => p.Modifier == "DefaultAutomationName"); + } + [Fact] public void AppliedModifierMap_EmptyModifiers_ReportsNothing() { diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs index f3654ab87..2d92e8d72 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs @@ -48,6 +48,19 @@ public void ListEdge_ReportsTheAuthoredSequence_NotTheSubscriptionSet() edges.Select(e => (e.ExpectedTargetTypeName!, e.Index))); } + [Fact] + public void EffectiveLabeledById_TheOutermostDecoratorWins() + { + var inner = Microsoft.UI.Reactor.Factories.Button("x").LabeledBy("inner-label"); + var outerOnly = Microsoft.UI.Reactor.Factories.Flyout(Microsoft.UI.Reactor.Factories.Button("x"), Microsoft.UI.Reactor.Factories.TextBlock("m")).LabeledBy("outer-label"); + var both = Microsoft.UI.Reactor.Factories.Flyout(inner, Microsoft.UI.Reactor.Factories.TextBlock("m")).LabeledBy("outer-label"); + var innerOnly = Microsoft.UI.Reactor.Factories.Flyout(inner, Microsoft.UI.Reactor.Factories.TextBlock("m")); + + Assert.Equal("outer-label", ReferenceEdgeMap.EffectiveLabeledById(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(outerOnly))); + Assert.Equal("outer-label", ReferenceEdgeMap.EffectiveLabeledById(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(both))); + Assert.Equal("inner-label", ReferenceEdgeMap.EffectiveLabeledById(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(innerOnly))); + } + [Fact] public void PendingModifierRef_IsReportedUnresolved() { From 7e6bb2880ce2dc1c61e06154704824ea1c0b4625 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 20:21:06 -0700 Subject: [PATCH 15/47] Diagnostics: array rank in type names; null typed props keep a row; fix test reference Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 9 +++++++-- .../SelfTest/Fixtures/ContentDialogTagFixtures.cs | 2 +- tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs | 2 ++ .../Diagnostics/ReactorDiagnosticsComponentsTests.cs | 7 +++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 1f0805973..f074cfed0 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -35,7 +35,7 @@ internal static string FriendlyTypeName(Type type) } if (type == typeof(object)) return "object"; if (type.IsArray && type.GetElementType() is { } element) - return FriendlyTypeName(element) + "[]"; + return FriendlyTypeName(element) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; var name = TypeName(type); return type.IsGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FriendlyTypeName))}>" @@ -318,7 +318,12 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul "(never its ToString(), which could print a secret member), so it degrades rather than fails.")] internal static IReadOnlyList<(string Name, Type Type, object? Value)> PropRows(object? props, Type? declared) { - if (props is null) return global::System.Array.Empty<(string, Type, object?)>(); + // 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 secret-bearing props type stays one row, so Format redacts it whole instead of the // members of, say, a SessionToken(string Value) being listed one by one. diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs index 1639dd53a..3cc52bf9c 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogTagFixtures.cs @@ -10,7 +10,7 @@ namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; /// (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 ReactorDiagnosticsFollowupsTests. +/// The tagging gate itself is unit-tested in ContentDialogDiagnosticsTagTests. /// internal static class ContentDialogTagFixtures { diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 4edc5a99a..6677dbe1c 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -25,6 +25,8 @@ public class DiagnosticTextTests [InlineData(typeof(Dictionary>), "Dictionary>")] [InlineData(typeof(DayOfWeek), "DayOfWeek")] [InlineData(typeof(DayOfWeek?), "DayOfWeek?")] + [InlineData(typeof(int[,]), "int[,]")] + [InlineData(typeof(string[,,]), "string[,,]")] public void FriendlyTypeName_SpellsTypesAsCSharp(Type type, string expected) => Assert.Equal(expected, DiagnosticText.FriendlyTypeName(type)); diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 6f8ee35b8..d36532671 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -472,6 +472,13 @@ private sealed class FieldOnlyProps(string password) public override string ToString() => $"pw={AdminPassword}"; } + [Fact] + public void PropRows_TypedNullProps_KeepAPropsRow_PropslessHasNone() + { + Assert.Equal(new[] { ("Props", typeof(string), (object?)null) }, DiagnosticText.PropRows(null, typeof(string))); + Assert.Empty(DiagnosticText.PropRows(null, null)); + } + [Fact] public void Props_OfASecretBearingType_AreOneRedactedRow_NotListedMemberByMember() { From f1285a6e00d3d102f5708cb9f54f9fe00cb0b3d4 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 22:07:24 -0700 Subject: [PATCH 16/47] Diagnostics: keep composed-property contributors; redact space-led quoted secrets - DescribeChain keeps every contributor to a composed property (Margin plus MarginInlineStart, ...). Across decorator levels a modifier replaces its own earlier value, and a whole-value modifier replaces all inner contributors, while inline-edge overlays build on them. - The labelled-secret pattern allows whitespace after an opening value quote. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/AppliedModifierMap.cs | 22 ++++++++++++------- .../Core/Diagnostics/DiagnosticText.cs | 2 +- .../Diagnostics/DiagnosticTextTests.cs | 2 ++ .../ReactorDiagnosticsComponentsTests.cs | 18 +++++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index 34dc1fc49..976eaba2a 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -163,6 +163,10 @@ internal static IReadOnlyList Describe(ElementModifiers modifie /// 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 @@ -172,21 +176,23 @@ internal static IReadOnlyList Describe(ElementModifiers modifie internal static IReadOnlyList DescribeChain(IReadOnlyList chain, Type controlType, string? liveName) { var merged = new List(); - var byProperty = new Dictionary(StringComparer.Ordinal); bool explicitName = false; for (int level = chain.Count - 1; level >= 0; level--) { if (chain[level].Modifiers is not { } modifiers) continue; explicitName |= modifiers.AutomationName is { Length: > 0 }; - foreach (var m in Describe(modifiers, controlType)) + 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) { - if (byProperty.TryGetValue(m.Property, out var at)) merged[at] = m; - else - { - byProperty[m.Property] = merged.Count; - merged.Add(m); - } + 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 (!explicitName && DescribeDefaultAutomationName(chain[^1], liveName) is { } defaultName) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index f074cfed0..5f96b08a1 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -127,7 +127,7 @@ internal static bool IsSecretName(string? name) // one, "AccessToken=…" in a connection-style string, "\"Password\":\"…\"" in JSON. A bare label // ("Password:") or an empty quoted value has nothing after the separator and does not match. private static readonly global::System.Text.RegularExpressions.Regex s_secretMemberInText = new( - @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*[""']?[^\s,;}\]""']", + @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*(?:[""']\s*)?[^\s,;}\]""']", global::System.Text.RegularExpressions.RegexOptions.IgnoreCase | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant); /// diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 6677dbe1c..dc894bd1b 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -214,6 +214,7 @@ public void Format_RedactsHandWrittenTextNamingASecretMember(string text) [InlineData("apiKey: hunter2")] [InlineData("{\"Password\":\"hunter2\"}")] [InlineData("{ 'accessToken': 'hunter2' }")] + [InlineData("{\"Password\":\" hunter2\"}")] public void Format_RedactsAStringCarryingALabelledSecret(string text) { var (shown, redacted) = DiagnosticText.Format("", typeof(string), text); @@ -249,6 +250,7 @@ public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() // A bare label is ordinary UI text, as is a JSON member with an empty value. Assert.Equal(("Password:", false), DiagnosticText.FormatPlain("ToolTip", "Password:")); Assert.Equal(("{\"Password\":\"\"}", false), DiagnosticText.FormatPlain("ToolTip", "{\"Password\":\"\"}")); + Assert.Equal(("{\"Password\":\" \"}", false), DiagnosticText.FormatPlain("ToolTip", "{\"Password\":\" \"}")); } // ── Parsing ───────────────────────────────────────────────────── diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index d36532671..23c7c05b7 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -775,6 +775,24 @@ public void DescribeChain_MergesDecoratorModifiersInApplicationOrder_OuterWins() Assert.DoesNotContain(merged, p => p.Modifier == "DefaultAutomationName"); } + [Fact] + public void DescribeChain_KeepsEveryContributorToAComposedProperty() + { + var sameLevel = Button("Go").Margin(4).MarginInlineStart(8); + var overlayOuter = Flyout(Button("Go").Margin(4), TextBlock("menu")).MarginInlineStart(8); + var wholeOuter = Flyout(Button("Go").Margin(4).MarginInlineStart(8), TextBlock("menu")).Margin(2); + + static string[] Margins(Element e) => AppliedModifierMap + .DescribeChain(Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain(e), typeof(WinUI.Button), null) + .Where(p => p.Property == "FrameworkElement.Margin").Select(p => p.Modifier).ToArray(); + + Assert.Equal(new[] { "Margin", "MarginInlineStart" }, Margins(sameLevel)); + // An outer inline edge overlays the inner physical margin, so both contributed. + Assert.Equal(new[] { "Margin", "MarginInlineStart" }, Margins(overlayOuter)); + // An outer physical margin rewrites the whole value, replacing every inner contributor. + Assert.Equal(new[] { "Margin" }, Margins(wholeOuter)); + } + [Fact] public void DescribeChain_ADecoratorsExplicitNameSuppressesTheTargetsDefault() { From 5d3dd0fb3c87205d08723286141e48aec201140e Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 22:20:04 -0700 Subject: [PATCH 17/47] Diagnostics: no LabeledBy edge rebuilt from an unmounted control's tag An unmounted unpooled control keeps its source-map tag after its reference state is torn down. The resolved AutomationId LabeledBy is now recovered only while the control is loaded (selftest, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Diagnostics/ReactorDiagnostics.References.cs | 5 ++++- .../SelfTest/Fixtures/ReferenceEdgeFixtures.cs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index b4e48da6c..77b70bb4c 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -45,7 +45,7 @@ public static partial class ReactorDiagnostics /// 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 when the control is tagged with its element (turn source mapping on). Reads existing + /// 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] @@ -65,6 +65,9 @@ public static IReadOnlyList GetReferenceEdges(UIElement c 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]) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs index 36d1d928d..3d534efb6 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReferenceEdgeFixtures.cs @@ -145,6 +145,21 @@ public override async Task RunAsync() var after = ReactorDiagnostics.GetReferenceEdges(box).SingleOrDefault(e => e.Property == "LabeledBy"); H.Check("RefEdges_UnmountedLabelNotResolved", after is { TargetAutomationId: "refedge-live-label", IsResolved: false, Target: null }); + + // The source itself unmounts after resolving: a retained, unpooled control keeps + // its tag, but no edge is rebuilt from it. + host.Mount(ctx => VStack( + TextBlock("refedge-src-label").AutomationId("refedge-src-label-id"), + CheckBox(label: "refedge-src-check").LabeledBy("refedge-src-label-id"))); + await Harness.Render(); + await Harness.Render(50); + var srcCheck = H.FindControl(c => c.Content as string == "refedge-src-check"); + H.Check("RefEdges_SourceResolvedBeforeUnmount", + srcCheck is not null && ReactorDiagnostics.GetReferenceEdges(srcCheck) is [{ IsResolved: true, TargetAutomationId: "refedge-src-label-id" }]); + if (srcCheck is null) return; + host.Mount(ctx => TextBlock("refedge-src-replaced")); + await Harness.WaitFor(() => !srcCheck.IsLoaded, maxPasses: 16, perPassMs: 10); + H.Check("RefEdges_UnmountedSourceReportsNoEdge", ReactorDiagnostics.GetReferenceEdges(srcCheck).Count == 0); } finally { From 44314822427853ee481743112020adc263b45f59 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 5 Oct 2026 22:41:25 -0700 Subject: [PATCH 18/47] Diagnostics: resolve host roots against the rendered root; nullable doc example - ReactorHost and ReactorHostControl publish the root that produced _currentControl together with it. Root resolution uses that rendered identity, so between Mount and its queued render the displayed control is still described (and edited) as the previous root (selftests, mutation-checked on both hosts). - DiagnosticValue.Type docs use int? and note that nullable reference annotations are not visible at runtime. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ReactorDiagnostics.Components.cs | 5 ++-- src/Reactor/Core/Reconciler.Diagnostics.cs | 23 +++++++++++++++++++ src/Reactor/Hosting/ReactorHost.cs | 20 ++++++++++------ .../Hosting/ReactorHostControl.Diagnostics.cs | 14 +++++------ src/Reactor/Hosting/ReactorHostControl.cs | 5 ++++ .../Fixtures/ComponentInspectionFixtures.cs | 13 +++++++++++ 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 8436f8cdc..daa2c0708 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -37,8 +37,9 @@ public sealed record ComponentSnapshot( /// "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, string?, -/// List<TaskItem>). Empty when the hook has no value. +/// 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, secrets diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 3298fb9a4..5ff759ba1 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -129,3 +129,26 @@ internal readonly record struct RootComponentSource( UIElement? RenderedControl, Element? RenderedElement, Func IsAlive); + +/// +/// The root that produced a host's current control, published by the host in the same step +/// that publishes the control. Mount swaps the requested root before the queued render +/// replaces the control, so diagnostics resolve against this rather than the requested root. +/// +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; + + internal bool SameRootAs(in RenderedRoot other) + => ReferenceEquals(Component, other.Component) && ReferenceEquals(FuncContext, other.FuncContext); +} diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 8930501be..790a7980e 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -36,6 +36,8 @@ public sealed class ReactorHost : IDisposable private Element? _currentTree; private UIElement? _currentControl; + // The root that produced _currentControl (diagnostics); see RenderedRoot. + private RenderedRoot _renderedRoot; 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 @@ -199,16 +201,17 @@ public ReactorWindow? OwningWindow || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); if (!isAnchor) return null; - var component = _rootComponent; - var funcContext = _funcContext; - if (component is null && funcContext is null) return null; + // The root that produced the displayed control, not the requested one: after Mount and + // before its queued render, the old control still shows the previous root. + var rendered = _renderedRoot; + if (rendered.IsEmpty) return null; return new RootComponentSource( - component, - component is null ? funcContext : null, - component is null ? _rootRenderFunc : null, + rendered.Component, + rendered.FuncContext, + rendered.RenderFunc, control, _currentTree, - () => !_disposed && ReferenceEquals(_rootComponent, component) && ReferenceEquals(_funcContext, funcContext)); + () => !_disposed && _renderedRoot.SameRootAs(rendered)); } /// @@ -732,6 +735,7 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _currentControl = newControl; _currentTree = newTree; + _renderedRoot = new RenderedRoot(_rootComponent, _funcContext, _rootRenderFunc); OwningWindow?.OnHostContentRendered(newControl); // Spec 033 §6 — apply (or clear) the SystemBackdrop modifier carried on @@ -1026,6 +1030,7 @@ public void Dispose() _funcContext = null; _currentTree = null; _currentControl = null; + _renderedRoot = default; try { _overlayWiring?.Dispose(); } catch { /* best effort */ } _overlayWiring = null; @@ -1054,5 +1059,6 @@ private void ShowErrorFallback(Exception ex) } _currentControl = errorPanel; _currentTree = null; + _renderedRoot = default; } } diff --git a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs index cf0ec723d..b7f7d77bd 100644 --- a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs +++ b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs @@ -24,15 +24,15 @@ public sealed partial class ReactorHostControl || (contentIsOurs && ReferenceEquals(element, content)); if (!isAnchor) return null; - var component = _rootComponent; - var funcContext = _funcContext; - if (component is null && funcContext is null) 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( - component, - component is null ? funcContext : null, - component is null ? _rootRenderFunc : null, + rendered.Component, + rendered.FuncContext, + rendered.RenderFunc, control, _currentTree, - () => !_disposed && ReferenceEquals(_rootComponent, component) && ReferenceEquals(_funcContext, funcContext)); + () => !_disposed && _renderedRoot.SameRootAs(rendered)); } } diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index b7d3d8180..9c200cf55 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -63,6 +63,8 @@ public sealed partial class ReactorHostControl : ContentControl, IDisposable private Element? _currentTree; private UIElement? _currentControl; + // The root that produced _currentControl (diagnostics); see RenderedRoot. + private RenderedRoot _renderedRoot; 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 @@ -635,6 +637,7 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _currentControl = newControl; _currentTree = newTree; + _renderedRoot = new RenderedRoot(_rootComponent, _funcContext, _rootRenderFunc); // Spec 033 §6 — Backdrop modifier on the root tree is a no-op for // ReactorHostControl, which doesn't own its hosting Window. We @@ -787,6 +790,7 @@ private void ShowErrorFallback(Exception ex) } _currentControl = errorPanel; _currentTree = null; + _renderedRoot = default; } public void Dispose() @@ -811,6 +815,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 index fd4b2baec..7d7183abb 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -207,6 +207,10 @@ static bool IsRoot(ComponentSnapshot? s) => // 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 swaps the requested root at once but renders later; until then the + // displayed control still belongs to — and is described as — the previous root. + H.Check("RootAnchor_RemountGapDescribesDisplayedRoot", + ReactorDiagnostics.DescribeComponent(target) is { IsRoot: true, Name: "RootCounter", State: [{ Value: "7" }] }); await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child:c") is not null, maxPasses: 16, perPassMs: 10); var overlap = host.CurrentControl; @@ -264,6 +268,15 @@ await Harness.WaitFor(() => bare.CurrentControl is not null && ReferenceEquals(w H.Check("RootAnchor_HostControlStillDescribedAfterSwap", ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true }); hostControl.Content = rendered; + // Remount gap: the displayed (function) root is still what is described. + hostControl.Mount(new RootCounter()); + H.Check("RootAnchor_HostControlRemountGap", + ReactorDiagnostics.DescribeComponent(hostControl) is { IsRoot: true, Kind: "function", State: [{ Value: "\"hc\"" }] }); + 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" }); + hostControl.Dispose(); H.Check("RootAnchor_DisposedHostControlNotDescribed", ReactorDiagnostics.DescribeComponent(hostControl) is null); } From aaf3c9cc84e368b90d6be1c40e350fc937dca098 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 19:03:28 -0700 Subject: [PATCH 19/47] Diagnostics: spell nint/nuint as C# does Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 4 +++- tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 5f96b08a1..7e840044f 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -16,11 +16,13 @@ internal static class DiagnosticText internal const string Redacted = ""; internal const int MaxValueLength = 200; - /// int, string?, List<TaskItem>: the type as C# reads it. + /// 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 diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index dc894bd1b..7ba6cfa57 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -26,6 +26,8 @@ public class DiagnosticTextTests [InlineData(typeof(DayOfWeek), "DayOfWeek")] [InlineData(typeof(DayOfWeek?), "DayOfWeek?")] [InlineData(typeof(int[,]), "int[,]")] + [InlineData(typeof(nint), "nint")] + [InlineData(typeof(nuint?), "nuint?")] [InlineData(typeof(string[,,]), "string[,,]")] public void FriendlyTypeName_SpellsTypesAsCSharp(Type type, string expected) => Assert.Equal(expected, DiagnosticText.FriendlyTypeName(type)); From 16ec9aa1140cb330163914e4cbb97d7d23894eb3 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 19:25:46 -0700 Subject: [PATCH 20/47] Diagnostics: article-free refusal messages; precise render-path cost note - Refusals no longer pick "a"/"an": "List cannot be set from text", "'8' is not a valid Access", "hook 2 of has kind 'effect'". - CHANGELOG separates the per-component reference stores from the per-host-render rendered-root publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 ++++--- docs/_pipeline/apps/diagnostics/App.cs | 2 +- docs/guide/source-mapping.md | 2 +- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 4 ++-- .../Diagnostics/ReactorDiagnostics.Components.cs | 2 +- .../Diagnostics/DiagnosticTextTests.cs | 14 +++++++------- .../ReactorDiagnosticsComponentsTests.cs | 12 ++++++------ 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c79399cbc..9bede6acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,10 @@ Conventions for contributors: pending ones whose target has not mounted. Component snapshots and property lists are text — no live component, element or hook object — with secrets redacted; a resolved reference edge carries its mounted target control. Reads - are on demand and UI-thread-affine. The render path gains only two reference - stores: `UseContext` records the context it read, and reference-list wiring - keeps the authored list. With source mapping on, an open + 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. 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). diff --git a/docs/_pipeline/apps/diagnostics/App.cs b/docs/_pipeline/apps/diagnostics/App.cs index b4e9fbd09..e6ba0fc77 100644 --- a/docs/_pipeline/apps/diagnostics/App.cs +++ b/docs/_pipeline/apps/diagnostics/App.cs @@ -64,7 +64,7 @@ public static void Inspect(UIElement wrapper, UIElement control) // 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", "... is a ref hook; ..." + Console.WriteLine(error); // "'abc' is not a valid int", "... has kind 'ref'; ..." ReactorDiagnostics.Rerender(wrapper); // bypasses memoization, like a state change } diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index 356001ae1..d1ebbb21d 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -346,7 +346,7 @@ if (ReactorDiagnostics.DescribeComponent(wrapper) is { } component) // 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", "... is a ref hook; ..." + Console.WriteLine(error); // "'abc' is not a valid int", "... has kind 'ref'; ..." ReactorDiagnostics.Rerender(wrapper); // bypasses memoization, like a state change } diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 7e840044f..5fa1f77a3 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -251,7 +251,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul // otherwise not settable from text (a List, a record) — Editable says so. if (!IsEditable(type)) { - error = $"a {FriendlyTypeName(type)} value cannot be typed as text; only numbers, characters, text, true/false, enums, dates, times, GUIDs and their nullables can be set"; + 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)) @@ -273,7 +273,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul value = parsed; return true; } - error = $"'{text}' is not a {FriendlyTypeName(target)}; use one of {string.Join(", ", Enum.GetNames(target))}"; + error = $"'{text}' is not a valid {FriendlyTypeName(target)}; use one of {string.Join(", ", Enum.GetNames(target))}"; return false; } if (target == typeof(bool)) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index daa2c0708..3e4443c63 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -383,7 +383,7 @@ internal bool TrySetState(int hookIndex, string text, out string? error) var cell = _context.DiagnosticHookAt(hookIndex); if (!cell.DiagnosticCanSet) { - error = $"hook {hookIndex} of <{Name}> is a {HookKindText(cell.DiagnosticKind)} hook; only state, reducer and persisted hooks can be set"; + 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; diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 7ba6cfa57..45c65906a 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -294,13 +294,13 @@ public void TryParse_ParsesTextTypes(string text, Type type, object? expected) [InlineData("maybe", typeof(bool), "'maybe' is not true or false")] [InlineData("xy", typeof(char), "'xy' is not a single character")] [InlineData("Funday", typeof(DayOfWeek), "use one of Sunday, Monday")] - [InlineData("8", typeof(DayOfWeek), "'8' is not a DayOfWeek")] - [InlineData("8", typeof(Access), "'8' is not a Access")] - [InlineData("Read, 4", typeof(Access), "is not a Access; use one of None, Read, Write")] - [InlineData("x", typeof(List), "a List value cannot be typed as text")] - [InlineData("null", typeof(List), "a List value cannot be typed as text")] - [InlineData("null", typeof(object), "a object value cannot be typed as text")] - [InlineData("x", typeof(UserCredential), "a UserCredential value cannot be typed as text")] + [InlineData("8", typeof(DayOfWeek), "'8' is not a valid DayOfWeek")] + [InlineData("8", typeof(Access), "'8' is not a valid Access")] + [InlineData("Read, 4", typeof(Access), "is not a valid Access; use one of None, Read, Write")] + [InlineData("x", typeof(List), "List cannot be set from text")] + [InlineData("null", typeof(List), "List cannot be set from text")] + [InlineData("null", typeof(object), "object cannot be set from text")] + [InlineData("x", typeof(UserCredential), "UserCredential cannot be set from text")] public void TryParse_RefusesWithAReason(string text, Type type, string reason) { Assert.False(DiagnosticText.TryParse(text, type, out var value, out var error)); diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 23c7c05b7..8dd7bc42f 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -199,10 +199,10 @@ public void TrySetState_RefusesWithAReason_WithoutWriting() AssertRefused(handle, 0, "abc", "'abc' is not a valid int"); AssertRefused(handle, 0, "null", "'null' is not a valid int"); - AssertRefused(handle, 1, "6", "is a ref hook; only state, reducer and persisted hooks can be set"); - AssertRefused(handle, 2, "1", "is a effect hook"); - AssertRefused(handle, 3, "x", "a List value cannot be typed as text"); - AssertRefused(handle, 3, "null", "a List value cannot be typed as text"); + AssertRefused(handle, 1, "6", "has kind 'ref'; only state, reducer and persisted hooks can be set"); + AssertRefused(handle, 2, "1", "has kind 'effect'"); + AssertRefused(handle, 3, "x", "List cannot be set from text"); + AssertRefused(handle, 3, "null", "List cannot be set from text"); AssertRefused(handle, 4, "x", "holds a secret"); AssertRefused(handle, 5, "x", "holds a secret"); AssertRefused(handle, 6, "1", "has no hook 6"); @@ -250,8 +250,8 @@ public void State_HoldingARef_IsAStateHook_NotARefHook() Assert.Equal(("state", "Ref", false), (hooks[0].Kind, hooks[0].Type, hooks[0].Editable)); Assert.Equal(("ref", "int", "5"), (hooks[1].Kind, hooks[1].Type, hooks[1].Value)); // Refused for its type (a Ref cannot be typed as text), not as though it came from UseRef. - AssertRefused(handle, 0, "1", "a Ref value cannot be typed as text"); - AssertRefused(handle, 1, "1", "is a ref hook"); + AssertRefused(handle, 0, "1", "Ref cannot be set from text"); + AssertRefused(handle, 1, "1", "has kind 'ref'"); // reactor.state keeps naming a Ref cell "useRef", as it always has. Assert.Equal(new[] { "useRef", "useRef" }, r.Context.SnapshotHooks().Select(s => s.Hook)); } From 30cef95171974e723e0566611fcb6959e9735f03 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 19:44:54 -0700 Subject: [PATCH 21/47] Diagnostics: outermost automation name is in effect; CulturedFact for culture test - DescribeChain tracks the outermost (last-applied) AutomationName rather than OR-ing levels; an outer empty name over an inner one is pinned. - Format_PrimitivesUseInvariantCulture uses [CulturedFact("de-DE")] instead of mutating the ambient culture. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/AppliedModifierMap.cs | 10 ++++++---- .../Diagnostics/DiagnosticTextTests.cs | 15 +++++---------- .../ReactorDiagnosticsComponentsTests.cs | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index 976eaba2a..a62cb915e 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -171,16 +171,18 @@ internal static IReadOnlyList Describe(ElementModifiers modifie /// 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 no level set a non-empty explicit name. + /// 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(); - bool explicitName = false; + // 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; - explicitName |= modifiers.AutomationName is { Length: > 0 }; + 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 @@ -195,7 +197,7 @@ internal static IReadOnlyList DescribeChain(IReadOnlyList 0 } && DescribeDefaultAutomationName(chain[^1], liveName) is { } defaultName) merged.Add(defaultName); return merged; } diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 45c65906a..c5e95f6b3 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -40,18 +40,13 @@ public void Format_QuotesAndEscapesStrings() Assert.Equal("\"a \\\"b\\\"\\n\\\\c\"", DiagnosticText.Format("", typeof(string), "a \"b\"\n\\c").Text); } - [Fact] + // A comma-decimal culture discriminates: invariant output ("1.5") differs from de-DE ("1,5"). + [CulturedFact(new[] { "de-DE" })] public void Format_PrimitivesUseInvariantCulture() { - var previous = global::System.Globalization.CultureInfo.CurrentCulture; - try - { - global::System.Globalization.CultureInfo.CurrentCulture = new global::System.Globalization.CultureInfo("de-DE"); - Assert.Equal("1.5", DiagnosticText.Format("", typeof(double), 1.5).Text); - Assert.Equal("True", DiagnosticText.Format("", typeof(bool), true).Text); - Assert.Equal("null", DiagnosticText.Format("", typeof(string), null).Text); - } - finally { global::System.Globalization.CultureInfo.CurrentCulture = previous; } + Assert.Equal("1.5", DiagnosticText.Format("", typeof(double), 1.5).Text); + Assert.Equal("True", DiagnosticText.Format("", typeof(bool), true).Text); + Assert.Equal("null", DiagnosticText.Format("", typeof(string), null).Text); } [Fact] diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 8dd7bc42f..62868b541 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -793,6 +793,20 @@ static string[] Margins(Element e) => AppliedModifierMap Assert.Equal(new[] { "Margin" }, Margins(wholeOuter)); } + [Fact] + public void DescribeChain_TheOutermostNameIsInEffect_AnOuterEmptyNameOverridesAnInnerOne() + { + // The outer "" is applied last; a FlyoutElement has no caption, so no default is + // written over it and the live name stays empty. + var chain = Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.DecoratorChain( + Flyout(Button("Save").AutomationName("Save"), TextBlock("menu")).AutomationName("")); + + var merged = AppliedModifierMap.DescribeChain(chain, typeof(WinUI.Button), liveName: ""); + + Assert.Equal(new[] { new AppliedModifier("AutomationName", "AutomationProperties.Name", "") }, + merged.Where(p => p.Property == "AutomationProperties.Name")); + } + [Fact] public void DescribeChain_ADecoratorsExplicitNameSuppressesTheTargetsDefault() { From 7e97bc134febed391cf54e831a853e3643f3314e Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 20:05:09 -0700 Subject: [PATCH 22/47] Diagnostics: per-element UI-thread check; quoted truncation; LabeledBy ordering - Every public method checks the supplied element's own DispatcherQueue, so an off-thread read fails even in an embedded ReactorHostControl with no ReactorApp.UIDispatcher (selftest, mutation-checked). - Long strings are cut inside their quotes ("xxx...") and never split an escape sequence. - GetReferenceEdges keeps both LabeledBy forms adjacent; the ordering is documented as it is. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 18 ++++++++++--- .../ReactorDiagnostics.Components.cs | 27 +++++++++++++------ .../ReactorDiagnostics.References.cs | 21 ++++++++++++--- .../Fixtures/ComponentInspectionFixtures.cs | 23 ++++++++++++++++ .../Diagnostics/DiagnosticTextTests.cs | 12 +++++++-- .../ReactorDiagnosticsReferenceEdgesTests.cs | 14 ++++++++++ 6 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 5fa1f77a3..1ec9313a2 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -155,7 +155,7 @@ internal static (string Text, bool Redacted) Format(string name, Type? declared, { // A string can carry a labelled secret itself ("AccessToken=…", a connection string). if (s_secretMemberInText.IsMatch(s)) return (Redacted, true); - return (Truncate(Quote(s)), false); + return (Quote(s), false); } return FormatPlain(name, value); @@ -397,9 +397,19 @@ value is ICollection || } } - private static string Quote(string s) => - "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) - .Replace("\n", "\\n", StringComparison.Ordinal).Replace("\r", "\\r", StringComparison.Ordinal) + "\""; + 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 "\"" + cut + "...\""; + } private static string Truncate(string text) => text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "..." : text; diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 3e4443c63..82fc63d0f 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -103,7 +103,7 @@ public static partial class ReactorDiagnostics public static ComponentSnapshot? DescribeComponent(UIElement element) { ArgumentNullException.ThrowIfNull(element); - EnsureUIThread(); + EnsureUIThread(element); return ComponentHandle.TryResolve(element, out var handle) ? handle.Describe() : null; } @@ -129,7 +129,7 @@ public static bool TrySetState(UIElement element, int hookIndex, string text, ou { ArgumentNullException.ThrowIfNull(element); ArgumentNullException.ThrowIfNull(text); - EnsureUIThread(); + EnsureUIThread(element); if (!ComponentHandle.TryResolve(element, out var handle)) { error = "no Reactor component is hosted at this element"; @@ -153,7 +153,7 @@ public static bool TrySetState(UIElement element, int hookIndex, string text, ou public static bool Rerender(UIElement element) { ArgumentNullException.ThrowIfNull(element); - EnsureUIThread(); + EnsureUIThread(element); return ComponentHandle.TryResolve(element, out var handle) && handle.Rerender(); } @@ -177,7 +177,7 @@ public static bool Rerender(UIElement element) public static IReadOnlyList GetAppliedProperties(UIElement control) { ArgumentNullException.ThrowIfNull(control); - EnsureUIThread(); + 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); @@ -197,11 +197,22 @@ internal static AppliedProperty ToText(AppliedModifier m) /// /// 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. Shares ThreadAffinity's rule: unchecked before a UI dispatcher has - /// been captured (headless tests, pre-Run). + /// 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() - => Microsoft.UI.Reactor.Hosting.ThreadAffinity.ThrowIfNotOnUIThread("ReactorDiagnostics component inspection"); + 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"); + } } /// diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs index 77b70bb4c..63dd904da 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.References.cs @@ -38,8 +38,10 @@ 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. Ordered by - /// property, then list position. + /// 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 @@ -52,7 +54,7 @@ public static partial class ReactorDiagnostics public static IReadOnlyList GetReferenceEdges(UIElement control) { ArgumentNullException.ThrowIfNull(control); - EnsureUIThread(); + EnsureUIThread(control); Reconciler.ReactorState? state = null; if (control is FrameworkElement fe) Reconciler.TryGetReactorState(fe, out state); @@ -147,7 +149,18 @@ internal static IReadOnlyList Describe( } if (result is null) return global::System.Array.Empty(); - result.Sort(static (a, b) => a.Slot != b.Slot ? a.Slot.CompareTo(b.Slot) : a.Edge.Index.CompareTo(b.Edge.Index)); + // 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; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 16a45765f..f8e7cf2db 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -22,6 +22,12 @@ internal static class ComponentInspectionFixtures private sealed record CounterProps(int Step, string AdminPassword); + private static Exception? Record(Action action) + { + try { action(); return null; } + catch (Exception ex) { return ex; } + } + private sealed class Counter : Component { public override Element Render() @@ -60,6 +66,23 @@ public override async Task RunAsync() 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"); var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", counter is { Name: "Counter", Kind: "class", IsRoot: false }); diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index c5e95f6b3..7f5b69a11 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -89,8 +89,16 @@ public void Format_ACollectionThatFormatsItself_KeepsItsText() public void Format_TruncatesLongValues() { var text = DiagnosticText.Format("", typeof(string), new string('x', 500)).Text; - Assert.Equal(DiagnosticText.MaxValueLength + 3, text.Length); - Assert.EndsWith("...", text); + // The payload is cut, and both quote delimiters survive. + Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength) + "...\"", text); + } + + [Fact] + public void Format_TruncationNeverSplitsAnEscapeSequence() + { + // 199 x's then a backslash: escaped, the cut would land between "\" and "\". + var text = DiagnosticText.Format("", typeof(string), new string('x', DiagnosticText.MaxValueLength - 1) + "\\tail").Text; + Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength - 1) + "...\"", text); } // ── Redaction ─────────────────────────────────────────────────── diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs index 2d92e8d72..4a40fb099 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsReferenceEdgesTests.cs @@ -138,6 +138,20 @@ public void AuthoredAutomationIdLabeledBy_WithoutALiveMatch_IsUnresolved() Assert.False(edge.IsResolved); } + [Fact] + public void BothLabeledByForms_AreAdjacent_BeforeLaterProperties() + { + var bag = Bag(); + AddScalar(bag, ReferenceSlots.ModifierRef_LabeledBy, new ElementRef()); + AddList(bag, ReferenceSlots.ModifierRef_DescribedBy, new ElementRef()); + AddScalar(bag, ReferenceSlots.ModifierRef_XYFocusUp, new ElementRef()); + + var edges = ReferenceEdgeMap.Describe(bag, pendingLabeledBy: "nameLabel", null, null); + + Assert.Equal(new[] { ("LabeledBy", (string?)null), ("LabeledBy", "nameLabel"), ("DescribedBy", null), ("XYFocusUp", null) }, + edges.Select(e => (e.Property, e.TargetAutomationId))); + } + [Fact] public void AutomationIdAndRefLabeledBy_AreReportedAsSeparateEdges() { From 4d61d1f03c409272469ca452cd2cf62ab0d8ca34 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 20:25:11 -0700 Subject: [PATCH 23/47] Diagnostics: skip reconcilers owned by another UI thread Hosts hand their dispatcher to their reconciler; component and root lookups skip any reconciler whose dispatcher lacks thread access before reading its tables or controls (selftest, mutation-checked). The selftest Record helper no longer catches fatal exceptions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.Diagnostics.cs | 12 +++++++++++ src/Reactor/Hosting/ReactorHost.cs | 1 + src/Reactor/Hosting/ReactorHostControl.cs | 1 + .../Fixtures/ComponentInspectionFixtures.cs | 21 ++++++++++++++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 5ff759ba1..6face40fe 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -27,6 +27,16 @@ public sealed partial class Reconciler /// internal Func? DiagnosticsRootResolver { get; set; } + /// + /// The owning host's UI dispatcher, set alongside . + /// The registry is process-wide while hosts can live on different UI threads, so a lookup + /// skips every reconciler whose dispatcher is not the calling thread's before touching its + /// unsynchronized tables or controls. Null (headless) is not filtered. + /// + internal Microsoft.UI.Dispatching.DispatcherQueue? DiagnosticsDispatcher { get; set; } + + private bool IsOwnedByAnotherThread => DiagnosticsDispatcher is { HasThreadAccess: false }; + private void RegisterForDiagnostics() { lock (s_liveGate) @@ -77,6 +87,7 @@ internal static bool TryFindComponentNode(UIElement element, out Reconciler owne { foreach (var r in SnapshotLiveReconcilers()) { + if (r.IsOwnedByAnotherThread) continue; if (r._componentNodes.TryGetValue(element, out var found)) { owner = r; @@ -94,6 +105,7 @@ internal static bool TryFindRootComponent(UIElement element, out RootComponentSo { foreach (var r in SnapshotLiveReconcilers()) { + if (r.IsOwnedByAnotherThread) continue; if (r.DiagnosticsRootResolver?.Invoke(element) is { } found) { root = found; diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index bcafc0799..7f377c513 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -260,6 +260,7 @@ public ReactorHost(Window window, ILogger? logger = null) _window = window; _backdropApplier = new BackdropApplier(window); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _reconciler.DiagnosticsDispatcher = _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 diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 48982de6d..282918ef9 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -224,6 +224,7 @@ public ReactorHostControl(Component? component = null, ILogger? logger = null) _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _reconciler.RenderErrorHandlerProvider = () => EffectiveRenderErrorHandler; _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _reconciler.DiagnosticsDispatcher = _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 diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index f8e7cf2db..f9921553e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -25,7 +25,10 @@ private sealed record CounterProps(int Step, string AdminPassword); private static Exception? Record(Action action) { try { action(); return null; } - catch (Exception ex) { return ex; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return ex; + } } private sealed class Counter : Component @@ -209,6 +212,22 @@ static bool IsRoot(ComponentSnapshot? s) => 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.DiagnosticsDispatcher; + var foreignThread = Microsoft.UI.Dispatching.DispatcherQueueController.CreateOnDedicatedThread(); + try + { + host.Reconciler.DiagnosticsDispatcher = foreignThread.DispatcherQueue; + H.Check("RootAnchor_ForeignThreadReconcilerSkipped", ReactorDiagnostics.DescribeComponent(target) is null); + } + finally + { + host.Reconciler.DiagnosticsDispatcher = 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; From 64d4d3001373499f0ba53ec20f58ce38b1f895f4 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 20:42:56 -0700 Subject: [PATCH 24/47] Diagnostics: a root that renders Empty() still resolves Both host resolvers no longer bail on a null rendered control: the ContentTarget (ReactorHost) and the control itself (ReactorHostControl) anchor an empty root, and the rendered-root check still rejects pre-render, error and disposed states (selftests, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Hosting/ReactorHost.cs | 4 ++- .../Hosting/ReactorHostControl.Diagnostics.cs | 3 +- .../Fixtures/ComponentInspectionFixtures.cs | 29 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 7f377c513..b03206ad6 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -191,8 +191,10 @@ public ReactorWindow? OwningWindow private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) { if (_disposed) return null; + // Null when the root rendered Empty(): the ContentTarget still anchors it (an empty + // container is this host's), and the rendered-root check below rejects pre-render, + // error and disposed states. var control = _currentControl; - if (control is null) return null; UIElement? container = ContentTarget ?? (_windowClosed ? null : _window.Content as UIElement); UIElement? installed = ContentTarget is { } target ? target.Child : container; diff --git a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs index b7f7d77bd..b434fdd14 100644 --- a/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs +++ b/src/Reactor/Hosting/ReactorHostControl.Diagnostics.cs @@ -14,8 +14,9 @@ public sealed partial class ReactorHostControl 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; - if (control is null) return null; var content = Content; var wrapper = _overlayWiring?.WrapperRoot; bool contentIsOurs = ReferenceEquals(content, control) || (wrapper is not null && ReferenceEquals(content, wrapper)); diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index f9921553e..5c2b4de16 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -297,6 +297,24 @@ await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child 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\"" }] }); + } + H.SetContent(null); + // A host without a ContentTarget installs its root as the window content. var window = H.Window; var previousContent = window.Content; @@ -350,6 +368,17 @@ await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is 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\"" }] }); + hostControl.Dispose(); H.Check("RootAnchor_DisposedHostControlNotDescribed", ReactorDiagnostics.DescribeComponent(hostControl) is null); } From ffd8960381bf1d7f78459150c423ec32fc803557 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 21:00:20 -0700 Subject: [PATCH 25/47] Diagnostics: explicit ContentTarget ownership for empty roots An empty container no longer counts as ownership by itself. A host claims its ContentTarget when it mounts and when it writes its content; an Empty() root resolves through the container only for that latest claimant (selftests for an unrelated empty container and two empty-root hosts sharing one, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +- src/Reactor/Hosting/ReactorHost.cs | 38 +++++++++++++++---- .../Fixtures/ComponentInspectionFixtures.cs | 18 +++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bede6acc..f584a3c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,8 @@ Conventions for contributors: 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. With source mapping on, an open + records which root produced the displayed control (and, on mount or a content + change, that it owns its `ContentTarget`). 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). diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index b03206ad6..482366e73 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -186,22 +186,26 @@ public ReactorWindow? OwningWindow /// 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. + /// is actually in it — or, while it is empty (an Empty() root), for the host that + /// last mounted into or wrote it. /// private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) { if (_disposed) return null; - // Null when the root rendered Empty(): the ContentTarget still anchors it (an empty - // container is this host's), and the rendered-root check below rejects pre-render, - // error and disposed states. + // Null when the root rendered Empty(); see the ownership rule below. var control = _currentControl; UIElement? container = ContentTarget ?? (_windowClosed ? null : _window.Content as UIElement); UIElement? installed = ContentTarget is { } target ? target.Child : container; var wrapper = _overlayWiring?.WrapperRoot; - bool ours = ReferenceEquals(installed, control) || (wrapper is not null && ReferenceEquals(installed, wrapper)); - - bool isAnchor = ReferenceEquals(element, control) + // Content identity proves ownership when there is content. An empty container proves + // nothing (any empty-root host sharing it would match), so then this host must be the + // one that last mounted into or wrote the ContentTarget. + bool ours = installed is not null + ? ReferenceEquals(installed, control) || (wrapper is not null && ReferenceEquals(installed, wrapper)) + : control is null && ContentTarget is { } empty && OwnsContentTarget(empty); + + bool isAnchor = (control is not null && ReferenceEquals(element, control)) || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); if (!isAnchor) return null; @@ -411,6 +415,7 @@ private void PushChartingState() public void Mount(Component component) { _rootComponent = component; + ClaimContentTarget(); RequestRender(); } @@ -418,9 +423,25 @@ public void Mount(Func renderFunc) { _rootRenderFunc = renderFunc; _funcContext = new RenderContext(); + ClaimContentTarget(); RequestRender(); } + // Diagnostics ownership of a shared ContentTarget: the host that last mounted into it or + // wrote its content. Only consulted when the container is empty (the root rendered Empty()), + // where content identity cannot say whose it is. Written on Mount and on content changes, + // never on an ordinary re-render. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_contentTargetOwner = new(); + + private void ClaimContentTarget() + { + if (ContentTarget is { } target) + s_contentTargetOwner.AddOrUpdate(target, this); + } + + private bool OwnsContentTarget(UIElement target) + => s_contentTargetOwner.TryGetValue(target, out var owner) && ReferenceEquals(owner, this); + /// /// Thread-safe: can be called from any thread. Coalesces multiple calls into /// a single render. At most one RenderLoop is ever pending on the dispatcher. @@ -738,7 +759,10 @@ 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; AttachThemeListener(newControl); diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 5c2b4de16..ef8eb940e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -312,6 +312,24 @@ await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child 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); + 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\"" }] }); } H.SetContent(null); From bf257eaa67aed02230f3b9cd4e10c73791363144 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 21:02:33 -0700 Subject: [PATCH 26/47] Diagnostics: filter foreign-thread reconcilers with Where Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.Diagnostics.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 6face40fe..b4ef89153 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -85,9 +85,8 @@ internal bool IsRegisteredForDiagnostics /// internal static bool TryFindComponentNode(UIElement element, out Reconciler owner, out ComponentNode node) { - foreach (var r in SnapshotLiveReconcilers()) + foreach (var r in SnapshotLiveReconcilers().Where(static r => !r.IsOwnedByAnotherThread)) { - if (r.IsOwnedByAnotherThread) continue; if (r._componentNodes.TryGetValue(element, out var found)) { owner = r; @@ -103,9 +102,8 @@ internal static bool TryFindComponentNode(UIElement element, out Reconciler owne /// Finds the host root component anchored at . internal static bool TryFindRootComponent(UIElement element, out RootComponentSource root) { - foreach (var r in SnapshotLiveReconcilers()) + foreach (var r in SnapshotLiveReconcilers().Where(static r => !r.IsOwnedByAnotherThread)) { - if (r.IsOwnedByAnotherThread) continue; if (r.DiagnosticsRootResolver?.Invoke(element) is { } found) { root = found; From 155e5c59e5aa8181f18f1608d308a0e456c8b6aa Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 21:33:26 -0700 Subject: [PATCH 27/47] Diagnostics: weak, dispose-released ContentTarget ownership; reclaim on every write - The ownership table holds the host weakly and Dispose releases the host's claim, so it never keeps a host, window or reconciler alive. - Every ContentTarget write (overlay install and teardown, error content) reclaims ownership, so an Empty() root leaving an overlay still resolves (selftests, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Hosting/ReactorHost.cs | 29 ++++++++++++++++--- .../Fixtures/ComponentInspectionFixtures.cs | 28 ++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 482366e73..35ccd5766 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -430,17 +430,30 @@ public void Mount(Func renderFunc) // Diagnostics ownership of a shared ContentTarget: the host that last mounted into it or // wrote its content. Only consulted when the container is empty (the root rendered Empty()), // where content identity cannot say whose it is. Written on Mount and on content changes, - // never on an ordinary re-render. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_contentTargetOwner = new(); + // never on an ordinary re-render. 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 { } target) - s_contentTargetOwner.AddOrUpdate(target, this); + s_contentTargetOwner.AddOrUpdate(target, new WeakReference(this)); + } + + private void ReleaseContentTarget() + { + if (ContentTarget is { } target && OwnsContentTarget(target)) + s_contentTargetOwner.Remove(target); } private bool OwnsContentTarget(UIElement target) - => s_contentTargetOwner.TryGetValue(target, out var owner) && ReferenceEquals(owner, this); + => 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 _); /// /// Thread-safe: can be called from any thread. Coalesces multiple calls into @@ -778,7 +791,10 @@ 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; Debug.WriteLine($"[Reactor.Overlay] wrapper installed mid-session; content={newControl?.GetType().Name ?? "null"}"); @@ -792,7 +808,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) // window. _overlayWiring.DetachContent(); if (ContentTarget is not null) + { ContentTarget.Child = newControl; + ClaimContentTarget(); + } else _window.Content = newControl; _overlayWiring.Dispose(); @@ -1119,6 +1138,7 @@ public void Dispose() _currentTree = null; _currentControl = null; _renderedRoot = default; + ReleaseContentTarget(); try { _overlayWiring?.Dispose(); } catch { /* best effort */ } _overlayWiring = null; @@ -1176,6 +1196,7 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep else if (ContentTarget is not null) { ContentTarget.Child = errorPanel; + ClaimContentTarget(); } else { diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index ef8eb940e..cdccda14f 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -330,6 +330,34 @@ emptyHost.CurrentControl is null await Harness.Render(); H.Check("RootAnchor_SharedEmptyContainerOwnedByLatestMount", ReactorDiagnostics.DescribeComponent(emptyTarget) is { IsRoot: true, State: [{ Value: "\"second-empty\"" }] }); + + // 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); From 780f2bb8335129f4fd5beafe3773ee81788af3f6 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 21:59:52 -0700 Subject: [PATCH 28/47] Diagnostics: never call a collection's ToString; ownership follows rendering; direct reconcilers - Every non-string IEnumerable is summarised from its advertised count; its own ToString (which may enumerate) is never called. - A host claims its ContentTarget only when it renders into it (a content write, or publishing an Empty() root), together with the rendered root, never at Mount. - A reconciler driven directly records its UI thread the first time it records a component, so foreign-thread lookups skip it too (selftests, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +-- .../Core/Diagnostics/DiagnosticText.cs | 6 ++--- src/Reactor/Core/Reconciler.Diagnostics.cs | 14 ++++++++++ src/Reactor/Core/Reconciler.Mount.cs | 3 +++ src/Reactor/Core/Reconciler.cs | 1 + src/Reactor/Hosting/ReactorHost.cs | 19 ++++++++------ .../Fixtures/ComponentInspectionFixtures.cs | 26 +++++++++++++++++++ .../Diagnostics/DiagnosticTextTests.cs | 17 +++++++----- 8 files changed, 70 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f584a3c6b..122d7b137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,8 @@ Conventions for contributors: 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, on mount or a content - change, that it owns its `ContentTarget`). With source mapping on, an open + 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). diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 1ec9313a2..33144b1f4 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -368,9 +368,9 @@ internal sealed class OpaqueValue(string 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. - private static bool IsListLike(object value) => - value is ICollection || - (value is IEnumerable and not string && OwnText(value, value.ToString) == value.GetType().ToString()); + // 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; /// /// The item count a collection advertises, without enumerating it: a lazy sequence can be diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index b4ef89153..07c2dea8a 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -35,6 +35,20 @@ public sealed partial class Reconciler /// internal Microsoft.UI.Dispatching.DispatcherQueue? DiagnosticsDispatcher { get; set; } + private bool _diagnosticsDispatcherProbed; + + /// + /// Records the owning UI thread of a reconciler driven directly (no host to supply it) the + /// first time it records a component, which only happens on that thread with a live wrapper. + /// One probe per reconciler; a no-op once a host or an earlier probe set it. + /// + private void CaptureDiagnosticsDispatcher() + { + if (_diagnosticsDispatcherProbed) return; + _diagnosticsDispatcherProbed = true; + DiagnosticsDispatcher ??= Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + } + private bool IsOwnedByAnotherThread => DiagnosticsDispatcher is { HasThreadAccess: false }; private void RegisterForDiagnostics() diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index e74fbe1d8..349281932 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -875,6 +875,7 @@ private UIElement MountComponent(ComponentElement compElement, Action requestRer Component = component, RenderedElement = null, Element = compElement, PreviousProps = compElement.Props, }; + CaptureDiagnosticsDispatcher(); _componentNodes[wrapper] = node; _boundaryMountJournal?.Add(wrapper); @@ -917,6 +918,7 @@ private UIElement MountFuncComponent(FuncElement funcElement, Action requestRere { Context = ctx, RenderedElement = null, Element = funcElement, }; + CaptureDiagnosticsDispatcher(); _componentNodes[wrapper] = node; _boundaryMountJournal?.Add(wrapper); @@ -960,6 +962,7 @@ private UIElement MountMemoComponent(MemoElement memoElement, Action requestRere Context = ctx, RenderedElement = null, Element = memoElement, MemoDependencies = memoElement.Dependencies, }; + CaptureDiagnosticsDispatcher(); _componentNodes[wrapper] = node; _boundaryMountJournal?.Add(wrapper); diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 9ab735bed..2c7eb2b93 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -2256,6 +2256,7 @@ internal bool TryAdoptRealizedReplacement(UIElement realized, UIElement replacem // node keyed on `realized` was already torn down + removed by the Unmount // inside ReconcileImperative; overwrite defensively regardless. _componentNodes.Remove(replacement); + CaptureDiagnosticsDispatcher(); _componentNodes[realized] = freshNode; // Move the fresh visual subtree into the parented wrapper. Assigning diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 35ccd5766..e11008734 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -187,7 +187,7 @@ public ReactorWindow? OwningWindow /// 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 mounted into or wrote it. + /// last rendered into it. /// private RootComponentSource? ResolveDiagnosticsRoot(UIElement element) { @@ -200,7 +200,7 @@ public ReactorWindow? OwningWindow 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 this host must be the - // one that last mounted into or wrote the ContentTarget. + // one that last rendered into the ContentTarget. bool ours = installed is not null ? ReferenceEquals(installed, control) || (wrapper is not null && ReferenceEquals(installed, wrapper)) : control is null && ContentTarget is { } empty && OwnsContentTarget(empty); @@ -415,7 +415,6 @@ private void PushChartingState() public void Mount(Component component) { _rootComponent = component; - ClaimContentTarget(); RequestRender(); } @@ -423,14 +422,14 @@ public void Mount(Func renderFunc) { _rootRenderFunc = renderFunc; _funcContext = new RenderContext(); - ClaimContentTarget(); RequestRender(); } - // Diagnostics ownership of a shared ContentTarget: the host that last mounted into it or - // wrote its content. Only consulted when the container is empty (the root rendered Empty()), - // where content identity cannot say whose it is. Written on Mount and on content changes, - // never on an ordinary re-render. The owner is held weakly and released on Dispose, so the + // 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(); @@ -821,6 +820,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _currentControl = newControl; _currentTree = newTree; _renderedRoot = new RenderedRoot(_rootComponent, _funcContext, _rootRenderFunc); + // An Empty() render may write nothing (the container was already empty), yet the + // container now shows this root: claim it alongside the published root. + if (newControl is null && ContentTarget is { } emptyTarget && !OwnsContentTarget(emptyTarget)) + ClaimContentTarget(); OwningWindow?.OnHostContentRendered(newControl); // Spec 033 §6 — apply (or clear) the SystemBackdrop modifier carried on diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index cdccda14f..90187d1e2 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -86,6 +86,17 @@ public override async Task RunAsync() finally { ReactorApp.UIDispatcher = savedDispatcher; } H.Check("CompInspect_OffThreadReadThrows", offThread is InvalidOperationException, offThread?.GetType().Name ?? "no exception"); + + // A reconciler driven directly (no host) records its UI thread the first time it + // mounts a component, so foreign-thread lookups can skip it too. + var direct = new Reconciler(); + try + { + var directWrapper = direct.Mount(Component(new CounterProps(1, "x")), () => { }); + H.Check("CompInspect_DirectReconcilerCapturesDispatcher", + directWrapper is not null && direct.DiagnosticsDispatcher is { HasThreadAccess: true }); + } + finally { direct.Dispose(); } var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", counter is { Name: "Counter", Kind: "class", IsRoot: false }); @@ -318,7 +329,22 @@ emptyHost.CurrentControl is null 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(); // …and of two empty-root hosts sharing one container, the latest to mount owns it. using var secondEmpty = new ReactorHost(H.Window) { ContentTarget = emptyTarget }; diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 7f5b69a11..e82e49244 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -80,9 +80,12 @@ public void Format_CompilerGeneratedCollectionType_IsNamedAfterTheDeclaredType() } [Fact] - public void Format_ACollectionThatFormatsItself_KeepsItsText() + public void Format_ACollectionIsSummarised_ItsOwnToStringIsNeverCalled() { - Assert.Equal("Bag[x]", DiagnosticText.Format("", typeof(SelfFormatting), new SelfFormatting()).Text); + var bag = new SelfFormatting(); + Assert.Equal($"{nameof(SelfFormatting)} (count unknown)", DiagnosticText.Format("", typeof(SelfFormatting), bag).Text); + Assert.Equal(0, bag.ToStringCalls); + Assert.Equal(0, bag.Enumerations); } [Fact] @@ -167,11 +170,9 @@ private sealed class HandWritten(string text) public override string ToString() => text; } - private sealed class ThrowsOnToString : IEnumerable + private sealed class ThrowsOnToString { public override string ToString() => throw new InvalidOperationException("boom"); - public IEnumerator GetEnumerator() { yield break; } - global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } private sealed class FormattableThrows : IFormattable @@ -334,8 +335,10 @@ private sealed class CountingSequence : IEnumerable private sealed class SelfFormatting : IEnumerable { - public IEnumerator GetEnumerator() { yield return "x"; } + public int ToStringCalls, Enumerations; + public IEnumerator GetEnumerator() { Enumerations++; yield return "x"; } global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); - public override string ToString() => "Bag[x]"; + // A ToString that enumerates, as a custom collection's might. + public override string ToString() { ToStringCalls++; return "Bag[" + string.Join(",", this) + "]"; } } } From 19d3701c7cb1b3e87ad6230566c27572a4642f1d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 22:16:50 -0700 Subject: [PATCH 29/47] Diagnostics: retargeting drops the earlier claim; public Count getter; secret types not editable - A host tracks the container it claimed; claiming a new one (or Dispose) removes the earlier entry, so a container it rendered into before cannot resolve to a root rendered elsewhere (selftest, mutation-checked). - TryCount reads Count only through a public getter. - Editable applies the same secret-type gate TrySetState does, so a null secret-typed value is not advertised as editable. - Selftest: using block for the direct reconciler. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 4 +++- .../ReactorDiagnostics.Components.cs | 4 +++- src/Reactor/Hosting/ReactorHost.cs | 18 ++++++++++++++---- .../Fixtures/ComponentInspectionFixtures.cs | 9 ++++++--- .../Diagnostics/DiagnosticTextTests.cs | 14 ++++++++++++++ .../ReactorDiagnosticsComponentsTests.cs | 15 +++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 33144b1f4..8c08a2583 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -386,7 +386,9 @@ internal sealed class OpaqueValue(string text) { if (value is ICollection collection) return collection.Count; var count = value.GetType().GetProperty("Count", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance); - if (count is not null && count.PropertyType == typeof(int) && count.GetIndexParameters().Length == 0) + // 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; } diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 82fc63d0f..ef7dea5fd 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -348,7 +348,9 @@ internal IReadOnlyList DescribeHooks(IReadOnlyList renderFunc) private void ClaimContentTarget() { - if (ContentTarget is { } target) - s_contentTargetOwner.AddOrUpdate(target, new WeakReference(this)); + if (ContentTarget is not { } 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 (ContentTarget is { } target && OwnsContentTarget(target)) + if (_claimedContentTarget is { } target && OwnsContentTarget(target)) s_contentTargetOwner.Remove(target); + _claimedContentTarget = null; } private bool OwnsContentTarget(UIElement target) - => s_contentTargetOwner.TryGetValue(target, out var owner) + => 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 diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 90187d1e2..50910d7a7 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -89,14 +89,12 @@ public override async Task RunAsync() // A reconciler driven directly (no host) records its UI thread the first time it // mounts a component, so foreign-thread lookups can skip it too. - var direct = new Reconciler(); - try + using (var direct = new Reconciler()) { var directWrapper = direct.Mount(Component(new CounterProps(1, "x")), () => { }); H.Check("CompInspect_DirectReconcilerCapturesDispatcher", directWrapper is not null && direct.DiagnosticsDispatcher is { HasThreadAccess: true }); } - finally { direct.Dispose(); } var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", counter is { Name: "Counter", Kind: "class", IsRoot: false }); @@ -345,6 +343,11 @@ emptyHost.CurrentControl is null 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); + 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 }; diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index e82e49244..786e54c04 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -49,6 +49,20 @@ public void Format_PrimitivesUseInvariantCulture() Assert.Equal("null", DiagnosticText.Format("", typeof(string), null).Text); } + [Fact] + public void TryCount_ReadsOnlyAPublicGetter() + { + Assert.Null(DiagnosticText.TryCount(new PrivateCountGetter())); + Assert.Equal("PrivateCountGetter (count unknown)", DiagnosticText.Format("", typeof(PrivateCountGetter), new PrivateCountGetter()).Text); + } + + private sealed class PrivateCountGetter : IEnumerable + { + public int Count { private get => throw new InvalidOperationException("private getter ran"); set { } } + public IEnumerator GetEnumerator() { yield break; } + global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [Fact] public void Format_SummarisesCollectionsByTheCountTheyAdvertise() { diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 62868b541..66444f597 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -219,6 +219,21 @@ private static void AssertRefused(ComponentHandle handle, int index, string text Assert.Contains(reason, error); } + [Fact] + public void State_ANullSecretTypedValue_IsNotAdvertisedEditable() + { + var r = new Renderer(); + r.Begin(); + r.Context.UseState(null); + var handle = ForContext(r.Context); + + var hook = Assert.Single(handle.Describe().State); + Assert.Equal(("null", false, false), (hook.Value, hook.Redacted, hook.Editable)); + AssertRefused(handle, 0, "On", "holds a secret"); + } + + private enum ApiSecret { Off, On } + [Fact] public void TrySetState_RefusesAValueItsNextSnapshotWouldRedact() { From 7bafe5816ffbe425f45ca5bd38353f4072aa517e Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 22:34:14 -0700 Subject: [PATCH 30/47] Diagnostics: scalar props stay one row; truncation keeps surrogate pairs whole - Dates, times, GUIDs (with primitives and decimal) are shown by value as a single Props row instead of being reflected member by member. - Truncation never leaves a lone high surrogate. - Test: the private-getter Count fixture stores its setter value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 21 ++++++++++++++----- .../Diagnostics/DiagnosticTextTests.cs | 14 ++++++++++++- .../ReactorDiagnosticsComponentsTests.cs | 12 +++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 8c08a2583..c915fa339 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -327,9 +327,10 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul ? global::System.Array.Empty<(string, Type, object?)>() : new[] { ("Props", declared, (object?)null) }; var type = props.GetType(); - // A secret-bearing props type stays one row, so Format redacts it whole instead of the - // members of, say, a SessionToken(string Value) being listed one by one. - if (type.IsPrimitive || props is string or decimal or Enum || IsListLike(props) + // A scalar (anything shown by its value, as Format and TryParse treat it) or a + // secret-bearing props type stays one row, so Format shows or redacts it whole instead of + // listing members (a Guid's none, a DateTime's Ticks/Kind/…, a SessionToken's Value). + if (IsScalar(type) || props is string or Enum || IsListLike(props) || HoldsSecretType(declared) || HoldsSecretType(type)) return new[] { ("Props", declared ?? type, (object?)props) }; @@ -410,9 +411,19 @@ private static string Quote(string s) 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 "\"" + cut + "...\""; + return "\"" + KeepWholeSurrogates(cut) + "...\""; } private static string Truncate(string text) => - text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "..." : 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/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 786e54c04..df1097748 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -58,7 +58,8 @@ public void TryCount_ReadsOnlyAPublicGetter() private sealed class PrivateCountGetter : IEnumerable { - public int Count { private get => throw new InvalidOperationException("private getter ran"); set { } } + private int _count; + public int Count { private get => throw new InvalidOperationException("private getter ran " + _count); set => _count = value; } public IEnumerator GetEnumerator() { yield break; } global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } @@ -110,6 +111,17 @@ public void Format_TruncatesLongValues() Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength) + "...\"", text); } + [Fact] + public void Format_TruncationNeverSplitsASurrogatePair() + { + // 199 x's then an emoji (two UTF-16 units): the cut would land between its halves. + var value = new string('x', DiagnosticText.MaxValueLength - 1) + "\U0001F600tail"; + var quoted = DiagnosticText.Format("", typeof(string), value).Text; + Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength - 1) + "...\"", quoted); + var plain = DiagnosticText.FormatPlain("Text", value).Text; + Assert.Equal(new string('x', DiagnosticText.MaxValueLength - 1) + "...", plain); + } + [Fact] public void Format_TruncationNeverSplitsAnEscapeSequence() { diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 66444f597..a5581dcf9 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -494,6 +494,18 @@ public void PropRows_TypedNullProps_KeepAPropsRow_PropslessHasNone() Assert.Empty(DiagnosticText.PropRows(null, null)); } + [Fact] + public void PropRows_ScalarValues_AreOneRowShownByValue() + { + var id = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"); + var when = new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc); + foreach (var scalar in new object[] { id, when, new DateTimeOffset(when), TimeSpan.FromMinutes(5), 1.5m, (nint)7 }) + { + var row = Assert.Single(DiagnosticText.PropRows(scalar, scalar.GetType())); + Assert.Equal(("Props", scalar), (row.Name, row.Value)); + } + } + [Fact] public void Props_OfASecretBearingType_AreOneRedactedRow_NotListedMemberByMember() { From 9fc9fa95c730f8f2e9a002e68cb1978ae370e5df Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Tue, 6 Oct 2026 22:53:36 -0700 Subject: [PATCH 31/47] Diagnostics: no lookup without a recorded dispatcher; context rows formatted once - A reconciler is inspected only from the UI thread it has recorded; one with no dispatcher yet is skipped (selftest, mutation-checked). - A context hook's State row reuses the Contexts row, so app formatting runs once per snapshot and both rows agree (test, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ReactorDiagnostics.Components.cs | 14 +++++++----- src/Reactor/Core/Reconciler.Diagnostics.cs | 11 ++++++---- .../Fixtures/ComponentInspectionFixtures.cs | 8 +++++++ .../ReactorDiagnosticsComponentsTests.cs | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index ef7dea5fd..483a4d3a6 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -338,13 +338,15 @@ internal IReadOnlyList DescribeHooks(IReadOnlyList c.Index == i) is { } contextRow) { - // A context hook's secret-ness can come from the context's name. - var redactionName = cell.DiagnosticKind == HookKind.Context - ? contexts?.FirstOrDefault(c => c.Index == i)?.Name ?? "" - : ""; - (text, redacted) = DiagnosticText.Format(redactionName, valueType, cell.DiagnosticValue); + // Already formatted for Contexts (with the context's name for redaction): reuse it, so + // one snapshot runs app formatting code once and both rows agree. + (text, redacted) = (contextRow.Value, contextRow.Redacted); + } + else if (cell.DiagnosticKind is not (HookKind.Effect or HookKind.NavigationLifecycle or HookKind.Unknown)) + { + (text, redacted) = DiagnosticText.Format("", valueType, cell.DiagnosticValue); } values[i] = new DiagnosticValue(i, "", HookKindText(cell.DiagnosticKind), valueType is null ? "" : DiagnosticText.FriendlyTypeName(valueType), text, diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 07c2dea8a..d2e7b3cb7 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -31,7 +31,7 @@ public sealed partial class Reconciler /// The owning host's UI dispatcher, set alongside . /// The registry is process-wide while hosts can live on different UI threads, so a lookup /// skips every reconciler whose dispatcher is not the calling thread's before touching its - /// unsynchronized tables or controls. Null (headless) is not filtered. + /// unsynchronized tables or controls. A reconciler with no dispatcher recorded is not inspected. /// internal Microsoft.UI.Dispatching.DispatcherQueue? DiagnosticsDispatcher { get; set; } @@ -49,7 +49,10 @@ private void CaptureDiagnosticsDispatcher() DiagnosticsDispatcher ??= Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); } - private bool IsOwnedByAnotherThread => DiagnosticsDispatcher is { HasThreadAccess: false }; + // Inspectable only from the UI thread it has recorded. A reconciler with no dispatcher yet + // (constructed, not yet hosted or mounting) is skipped: it has no live element to resolve, + // and its first mount may be mutating its tables on another thread. + private bool IsInspectableFromThisThread => DiagnosticsDispatcher is { HasThreadAccess: true }; private void RegisterForDiagnostics() { @@ -99,7 +102,7 @@ internal bool IsRegisteredForDiagnostics /// internal static bool TryFindComponentNode(UIElement element, out Reconciler owner, out ComponentNode node) { - foreach (var r in SnapshotLiveReconcilers().Where(static r => !r.IsOwnedByAnotherThread)) + foreach (var r in SnapshotLiveReconcilers().Where(static r => r.IsInspectableFromThisThread)) { if (r._componentNodes.TryGetValue(element, out var found)) { @@ -116,7 +119,7 @@ internal static bool TryFindComponentNode(UIElement element, out Reconciler owne /// Finds the host root component anchored at . internal static bool TryFindRootComponent(UIElement element, out RootComponentSource root) { - foreach (var r in SnapshotLiveReconcilers().Where(static r => !r.IsOwnedByAnotherThread)) + foreach (var r in SnapshotLiveReconcilers().Where(static r => r.IsInspectableFromThisThread)) { if (r.DiagnosticsRootResolver?.Invoke(element) is { } found) { diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 50910d7a7..433492aa1 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -94,6 +94,14 @@ public override async Task RunAsync() var directWrapper = direct.Mount(Component(new CounterProps(1, "x")), () => { }); H.Check("CompInspect_DirectReconcilerCapturesDispatcher", directWrapper is not null && direct.DiagnosticsDispatcher is { HasThreadAccess: true }); + H.Check("CompInspect_DirectReconcilerDescribed", + directWrapper is not null && ReactorDiagnostics.DescribeComponent(directWrapper) is { Name: "Counter" }); + // With no dispatcher recorded, a reconciler is never inspected from any thread. + var recorded = direct.DiagnosticsDispatcher; + direct.DiagnosticsDispatcher = null; + H.Check("CompInspect_UncapturedReconcilerNotInspected", + directWrapper is not null && ReactorDiagnostics.DescribeComponent(directWrapper) is null); + direct.DiagnosticsDispatcher = recorded; } var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index a5581dcf9..a4543c373 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -387,6 +387,28 @@ public void Contexts_ASlotSwitchedToAnotherContext_IsDescribedByTheOneLastRead() Assert.DoesNotContain("s3cr3t", snapshot.Contexts.Single().Value + snapshot.State.Single().Value); } + private sealed class CountingText + { + public int Calls; + public override string ToString() { Calls++; return "v" + Calls; } + } + + [Fact] + public void Contexts_AContextValueIsFormattedOnce_AndBothRowsAgree() + { + var ctx = new Context(null, "Counting"); + var value = new CountingText(); + var r = new Renderer(); + r.Scope.Push(new Dictionary { [ctx] = value }); + r.Begin(); + r.Context.UseContext(ctx); + + var snapshot = ForContext(r.Context).Describe(); + + Assert.Equal(1, value.Calls); + Assert.Equal(snapshot.Contexts.Single().Value, snapshot.State.Single().Value); + } + private sealed class EqualsThrows { public override bool Equals(object? obj) => throw new InvalidOperationException("boom"); From 92238acf7713b9722c8790b27bf55df7b8b2e13c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 14:56:27 -0700 Subject: [PATCH 32/47] Diagnostics: "1 item"; scope the UI-thread note to the inspection members Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/_pipeline/templates/source-mapping.md.dt | 5 +++-- docs/guide/source-mapping.md | 5 +++-- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 2 +- tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index ba34b961a..efa1b6738 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -312,8 +312,9 @@ small allocation per *component*, never per leaf, and nothing while the flag is `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. Every member -must be called on the UI thread. A `ComponentSnapshot` and the property lists are +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. diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index 659dd534a..c2c6af96b 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -377,8 +377,9 @@ small allocation per *component*, never per leaf, and nothing while the flag is `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. Every member -must be called on the UI thread. A `ComponentSnapshot` and the property lists are +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. diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index c915fa339..95474e0dd 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -146,7 +146,7 @@ internal static (string Text, bool Redacted) Format(string name, 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)} items" + ? $"{count.ToString(CultureInfo.InvariantCulture)} {(count == 1 ? "item" : "items")}" : "count unknown"; return ($"{FriendlyTypeName(shownType)} ({countText})", false); } diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index df1097748..8f3810e4e 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -70,7 +70,7 @@ public void Format_SummarisesCollectionsByTheCountTheyAdvertise() Assert.Equal("List (3 items)", DiagnosticText.Format("", typeof(List), new List { 1, 2, 3 }).Text); Assert.Equal("int[] (2 items)", DiagnosticText.Format("", typeof(int[]), new[] { 1, 2 }).Text); // HashSet has no non-generic ICollection; its public Count is read instead. - Assert.Equal("HashSet (1 items)", DiagnosticText.Format("", typeof(HashSet), new HashSet { "a" }).Text); + Assert.Equal("HashSet (1 item)", DiagnosticText.Format("", typeof(HashSet), new HashSet { "a" }).Text); } [Fact] From 5a9f8729fa12cbe46ea95211eb1f826d7dea1ca4 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 15:14:31 -0700 Subject: [PATCH 33/47] Diagnostics: round-trip format for DateTime/DateTimeOffset values Dates are shown with the "O" format, so a displayed value submitted back through TrySetState keeps its Kind, offset and fractional seconds (test, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 4 ++++ .../Diagnostics/DiagnosticTextTests.cs | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 95474e0dd..a36bfda64 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -182,6 +182,10 @@ internal static (string Text, bool Redacted) FormatPlain(string name, object? va 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; diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 8f3810e4e..b9cf4ac37 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -64,6 +64,21 @@ private sealed class PrivateCountGetter : IEnumerable global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } + [Fact] + public void Format_DatesRoundTripThroughTryParse() + { + var utc = new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + var local = new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.FromHours(-7)).AddTicks(89); + foreach (var (type, value) in new (Type, object)[] { (typeof(DateTime), utc), (typeof(DateTimeOffset), local) }) + { + var text = DiagnosticText.Format("", type, value).Text; + Assert.True(DiagnosticText.TryParse(text, type, out var parsed, out var error), error); + Assert.Equal(value, parsed); + if (parsed is DateTime d) Assert.Equal(DateTimeKind.Utc, d.Kind); + if (parsed is DateTimeOffset o) Assert.Equal(local.Offset, o.Offset); + } + } + [Fact] public void Format_SummarisesCollectionsByTheCountTheyAdvertise() { From b91c113ca5c8b520b724d2ea12855a2dac4c3a5f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 15:42:34 -0700 Subject: [PATCH 34/47] Diagnostics: props whose own text labels a secret are one redacted row A non-record props object whose hand-written ToString() names a secret with a value is withheld whole (Redacted) instead of listed member by member; a record's generated text is not used, since its rows are already redacted one by one (tests, mutation-checked on both arms). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 31 +++++++++++++++++++ .../ReactorDiagnosticsComponentsTests.cs | 21 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index a36bfda64..a0feb200b 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -138,6 +138,7 @@ internal static bool IsSecretName(string? name) /// internal static (string Text, bool Redacted) Format(string name, Type? declared, object? value) { + if (value is SecretValue) return (Redacted, true); if (value is not null && (IsSecretName(name) || HoldsSecretType(declared) || HoldsSecretType(value.GetType()))) return (Redacted, true); @@ -338,6 +339,14 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul || HoldsSecretType(declared) || HoldsSecretType(type)) return new[] { ("Props", declared ?? type, (object?)props) }; + // A hand-written ToString() that labels a secret ("ApiKey: …") marks the whole object as + // secret, the same rule Format applies to a value's own text, so its members are not listed + // one by one. A record's compiler-generated text only lists its members, which are already + // redacted row by row, so records are not judged by it. + if (HasOwnToString(type) && !IsRecord(type) + && s_secretMemberInText.IsMatch(OwnText(props, props.ToString))) + return new[] { ("Props", declared ?? type, (object?)SecretValue.Instance) }; + 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) @@ -370,6 +379,28 @@ internal sealed class OpaqueValue(string text) public override string ToString() => text; } + /// Stands in for a value withheld as a secret; reports it redacted. + internal sealed class SecretValue + { + internal static readonly SecretValue Instance = new(); + private SecretValue() { } + public override string ToString() => Redacted; + } + + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "Diagnostics-only probe for a ToString override; trimmed metadata reports none, so no own-text check runs.")] + private static bool HasOwnToString(Type type) + => type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes) is { } m && m.DeclaringType != typeof(object) + && m.DeclaringType != typeof(ValueType); + + // Every record class gets a compiler-generated public "$" method (and EqualityContract). + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "Diagnostics-only record probe; if trimmed, the record is judged by its own text, which can only redact more.")] + private static bool IsRecord(Type type) + => type.GetMethod("$", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance) is not null + || type.GetProperty("EqualityContract", + global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance) is not null; + // 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. diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index d352ccb7a..10f50679a 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -516,6 +516,27 @@ public void PropRows_TypedNullProps_KeepAPropsRow_PropslessHasNone() Assert.Empty(DiagnosticText.PropRows(null, null)); } + [Fact] + public void Props_WhoseOwnTextLabelsASecret_AreOneRedactedRow_RecordsStillListMembers() + { + var labelled = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new ApiConfig()) }; + var row = Assert.Single(ComponentHandle.FromNode(labelled, () => true).Describe().Props); + Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); + + // A record's generated text names AdminPassword too, but its rows are redacted one by one. + var record = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new AdminRecord(2, "hunter2")) }; + var rows = ComponentHandle.FromNode(record, () => true).Describe().Props; + Assert.Equal(new[] { ("Step", "2", false), ("AdminPassword", "", true) }, rows.Select(r => (r.Name, r.Value, r.Redacted))); + } + + private sealed class ApiConfig + { + public string Value => "hunter2"; + public override string ToString() => "ApiKey: " + Value; + } + + private sealed record AdminRecord(int Step, string AdminPassword); + [Fact] public void PropRows_ScalarValues_AreOneRowShownByValue() { From b95e5c6500118e14d4ae56550a3252116e5abd36 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 16:01:33 -0700 Subject: [PATCH 35/47] Diagnostics: exempt only fully generated record text; accept AOT redaction - The own-text check is skipped only for a record whose ToString and PrintMembers are both [CompilerGenerated]; a hand-written record ToString is judged like any object (test). - When trimming strips that metadata (NativeAOT) the props are judged by their own text and may become one redacted row; the selftest accepts that shape (the secret still never leaks). Fixes the AOT Selftests failure on CompInspect_ClassProps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 23 +++++++++++++------ .../Fixtures/ComponentInspectionFixtures.cs | 8 ++++--- .../ReactorDiagnosticsComponentsTests.cs | 10 ++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index a0feb200b..91d02eb6b 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -343,7 +343,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul // secret, the same rule Format applies to a value's own text, so its members are not listed // one by one. A record's compiler-generated text only lists its members, which are already // redacted row by row, so records are not judged by it. - if (HasOwnToString(type) && !IsRecord(type) + if (HasOwnToString(type) && !HasGeneratedRecordText(type) && s_secretMemberInText.IsMatch(OwnText(props, props.ToString))) return new[] { ("Props", declared ?? type, (object?)SecretValue.Instance) }; @@ -393,13 +393,22 @@ private static bool HasOwnToString(Type type) => type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes) is { } m && m.DeclaringType != typeof(object) && m.DeclaringType != typeof(ValueType); - // Every record class gets a compiler-generated public "$" method (and EqualityContract). + // A record whose text is entirely compiler-generated: ToString and PrintMembers both carry + // [CompilerGenerated], so the text only lists members (redacted row by row). A hand-written + // ToString or PrintMembers — or metadata trimmed away — fails this, and the own-text check runs. [UnconditionalSuppressMessage("Trimming", "IL2070", - Justification = "Diagnostics-only record probe; if trimmed, the record is judged by its own text, which can only redact more.")] - private static bool IsRecord(Type type) - => type.GetMethod("$", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance) is not null - || type.GetProperty("EqualityContract", - global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance) is not null; + Justification = "Diagnostics-only probe; if trimmed, the object is judged by its own text, which can only redact more.")] + private static bool HasGeneratedRecordText(Type type) + { + const global::System.Reflection.BindingFlags Any = + global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance; + if (type.GetMethod("$", Any) is null) return false; + var toString = type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes); + var printMembers = type.GetMethod("PrintMembers", Any, null, new[] { typeof(global::System.Text.StringBuilder) }, null); + return toString is not null && printMembers is not null + && toString.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false) + && printMembers.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false); + } // 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. diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 710607c24..167bcca1c 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -103,13 +103,15 @@ public override async Task RunAsync() 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 property metadata; Props then - // degrades to one "(members unavailable)" row. Either way the secret never leaks. + // NativeAOT/trimming may strip the props record's metadata; Props then degrades to one + // "(members unavailable)" row, or — when the record probe is trimmed too — one + // redacted row judged by the record's own text. Either way the secret never leaks. var fullProps = counter is not null && counter.Props.Count == 2 && counter.Props[0] is { Name: "Step", Type: "int", Value: "2", Redacted: false } && counter.Props[1] is { Name: "AdminPassword", Value: "", Redacted: true }; - var opaqueProps = counter is { Props: [{ Name: "Props", Value: "CounterProps (members unavailable)" }] }; + var opaqueProps = counter is { Props: [{ Name: "Props", Value: "CounterProps (members unavailable)" }] } + || counter is { Props: [{ Name: "Props", Value: "", Redacted: true }] }; H.Check("CompInspect_ClassProps", counter is not null && !counter.Props.Any(p => p.Value.Contains("hunter2", StringComparison.Ordinal)) diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 10f50679a..d28a3316b 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -527,6 +527,16 @@ public void Props_WhoseOwnTextLabelsASecret_AreOneRedactedRow_RecordsStillListMe var record = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new AdminRecord(2, "hunter2")) }; var rows = ComponentHandle.FromNode(record, () => true).Describe().Props; Assert.Equal(new[] { ("Step", "2", false), ("AdminPassword", "", true) }, rows.Select(r => (r.Name, r.Value, r.Redacted))); + + // A record with a hand-written ToString is judged by that text like any other object. + var handWritten = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new HandWrittenRecord("hunter2")) }; + var hwRow = Assert.Single(ComponentHandle.FromNode(handWritten, () => true).Describe().Props); + Assert.Equal(("Props", "", true), (hwRow.Name, hwRow.Value, hwRow.Redacted)); + } + + private sealed record HandWrittenRecord(string Value) + { + public override string ToString() => "ApiKey: " + Value; } private sealed class ApiConfig From 6b5a0a7ac62ff0031e6977617faf4566a95314f3 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 16:23:18 -0700 Subject: [PATCH 36/47] Diagnostics: resolve a host root against its published container ReactorHost resolves root anchors against the container the displayed root was rendered into (the claimed ContentTarget, or the window content once a window write releases the claim), not the requested ContentTarget. Retargeting takes effect when the next render publishes, so the previous container keeps anchoring the root through that gap (selftest, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Hosting/ReactorHost.cs | 27 +++++++++++++++---- .../Fixtures/ComponentInspectionFixtures.cs | 4 +++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index eefce6d9f..56649c02d 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -199,15 +199,22 @@ public ReactorWindow? OwningWindow // Null when the root rendered Empty(); see the ownership rule below. var control = _currentControl; - UIElement? container = ContentTarget ?? (_windowClosed ? null : _window.Content as UIElement); - UIElement? installed = ContentTarget is { } target ? target.Child : container; + // 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 + ?? (ContentTarget is null && _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 this host must be the - // one that last rendered into the ContentTarget. + // 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 && ContentTarget is { } empty && OwnsContentTarget(empty); + : control is null && published is not null; bool isAnchor = (control is not null && ReferenceEquals(element, control)) || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); @@ -838,7 +845,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) ClaimContentTarget(); } else + { _window.Content = contentToSet; + ReleaseContentTarget(); + } AttachThemeListener(newControl); } else if (anyOverlayOn && _overlayWiring!.WrapperRoot is null) @@ -857,7 +867,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) 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) @@ -874,7 +887,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) ClaimContentTarget(); } else + { _window.Content = newControl; + ReleaseContentTarget(); + } _overlayWiring.Dispose(); _overlayWiring = null; } @@ -1268,6 +1284,7 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep else { _window.Content = errorPanel; + ReleaseContentTarget(); } _currentControl = errorPanel; _currentTree = errorTree; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 167bcca1c..6c32fdc51 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -354,6 +354,10 @@ emptyHost.CurrentControl is null // 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. From 68c65ed66cc10158db06dae0dfd77568a7e36a11 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 16:41:09 -0700 Subject: [PATCH 37/47] Diagnostics: quoted secrets starting with punctuation; records with public fields - Quoted and unquoted values are matched separately: inside quotes any non-space character is a value (",hunter2"), while "" and " " stay plain labels. - Generated record text also prints public fields, which are not listed as rows, so a record with public fields is judged by its own text (test, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/DiagnosticText.cs | 10 ++++++++-- .../Reactor.Tests/Diagnostics/DiagnosticTextTests.cs | 2 ++ .../Diagnostics/ReactorDiagnosticsComponentsTests.cs | 11 +++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 91d02eb6b..da20dba50 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -127,9 +127,11 @@ internal static bool IsSecretName(string? name) // A member of a secret name followed by a value, inside an object's own text or a string: // "Password = …" in a record's compiler-generated ToString(), "ApiSecret: …" in a hand-written // one, "AccessToken=…" in a connection-style string, "\"Password\":\"…\"" in JSON. A bare label - // ("Password:") or an empty quoted value has nothing after the separator and does not match. + // ("Password:") or an empty or whitespace-only quoted value has nothing after the separator and + // does not match. Inside quotes any non-space character is a value (",hunter2" included); + // unquoted, a delimiter (",", ";", "}", "]") ends the label instead. private static readonly global::System.Text.RegularExpressions.Regex s_secretMemberInText = new( - @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*(?:[""']\s*)?[^\s,;}\]""']", + @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*(?:""\s*[^""\s]|'\s*[^'\s]|[^\s,;}\]""'])", global::System.Text.RegularExpressions.RegexOptions.IgnoreCase | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant); /// @@ -403,6 +405,10 @@ private static bool HasGeneratedRecordText(Type type) const global::System.Reflection.BindingFlags Any = global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance; if (type.GetMethod("$", Any) is null) return false; + // Generated text also prints public fields, which PropRows does not list, so a record with + // any is judged by its own text. + if (type.GetFields(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance).Length > 0) + return false; var toString = type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes); var printMembers = type.GetMethod("PrintMembers", Any, null, new[] { typeof(global::System.Text.StringBuilder) }, null); return toString is not null && printMembers is not null diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index b9cf4ac37..4da8361ff 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -262,6 +262,8 @@ public void Format_RedactsHandWrittenTextNamingASecretMember(string text) [InlineData("{\"Password\":\"hunter2\"}")] [InlineData("{ 'accessToken': 'hunter2' }")] [InlineData("{\"Password\":\" hunter2\"}")] + [InlineData("{\"Password\":\",hunter2\"}")] + [InlineData("{\"Password\":\";hunter2\"}")] public void Format_RedactsAStringCarryingALabelledSecret(string text) { var (shown, redacted) = DiagnosticText.Format("", typeof(string), text); diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index d28a3316b..967ee2fc0 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -532,6 +532,17 @@ public void Props_WhoseOwnTextLabelsASecret_AreOneRedactedRow_RecordsStillListMe var handWritten = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new HandWrittenRecord("hunter2")) }; var hwRow = Assert.Single(ComponentHandle.FromNode(handWritten, () => true).Describe().Props); Assert.Equal(("Props", "", true), (hwRow.Name, hwRow.Value, hwRow.Redacted)); + + // Generated text also prints public fields, which are not listed as rows: a record with a + // secret field is judged by its text rather than leaking its other members. + var withField = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new FieldRecord("visible", "hunter2")) }; + var fieldRow = Assert.Single(ComponentHandle.FromNode(withField, () => true).Describe().Props); + Assert.Equal(("Props", "", true), (fieldRow.Name, fieldRow.Value, fieldRow.Redacted)); + } + + private sealed record FieldRecord(string Value, string Stored) + { + public string AdminPassword = Stored; } private sealed record HandWrittenRecord(string Value) From 5c9cb08d54b55e0de537a526a1bd80a26290b5a7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 17:03:38 -0700 Subject: [PATCH 38/47] Test: readonly field in FieldRecord Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Diagnostics/ReactorDiagnosticsComponentsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 967ee2fc0..962ed62e4 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -542,7 +542,7 @@ public void Props_WhoseOwnTextLabelsASecret_AreOneRedactedRow_RecordsStillListMe private sealed record FieldRecord(string Value, string Stored) { - public string AdminPassword = Stored; + public readonly string AdminPassword = Stored; } private sealed record HandWrittenRecord(string Value) From 655c82d0fa4c9e8314f8f58126034e17449c4250 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 17:25:23 -0700 Subject: [PATCH 39/47] Reactor: a caption change replaces a truncated default automation name UpdateDefaultAutomationName compared the live name only with the full previous caption, so after a >100-character caption (whose default is truncated) Reactor mistook its own default for an author override and left the UIA name stale. The decision moves into a pure helper that also accepts the truncated form (test, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.cs | 19 +++++++++++++++---- ...orDiagnosticsDefaultAutomationNameTests.cs | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 3d1f588d1..a511970bd 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -3605,13 +3605,24 @@ public static void UpdateDefaultAutomationName(FrameworkElement fe, string? oldC if (fe is null) 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; + 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, diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs index 853bb21dd..36c8d6256 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsDefaultAutomationNameTests.cs @@ -62,6 +62,22 @@ public void DefaultAutomationName_UsesTheSameTruncationAsTheApplyPath() Assert.Null(AppliedModifierMap.DescribeDefaultAutomationName(Button(caption), caption)); } + [Fact] + public void DefaultAutomationName_UpdatePathRecognisesItsOwnTruncatedDefault() + { + var longCaption = new string('x', 140); + var truncated = Reconciler.DefaultAutomationNameFromCaption(longCaption); + + // Reactor wrote the truncated form at mount; a caption change must still replace it. + Assert.True(Reconciler.MayReplaceDefaultAutomationName(truncated, longCaption)); + Assert.True(Reconciler.MayReplaceDefaultAutomationName("Save", "Save")); + Assert.True(Reconciler.MayReplaceDefaultAutomationName("", "Save")); + Assert.True(Reconciler.MayReplaceDefaultAutomationName(null, null)); + // Anything else is the author's. + Assert.False(Reconciler.MayReplaceDefaultAutomationName("Save draft", "Save")); + Assert.False(Reconciler.MayReplaceDefaultAutomationName("Save", null)); + } + [Fact] public void DefaultAutomationName_UncaptionedOrBlankCaption_NotReported() { From 94c0e3cddcf0f31420cd07c5fdc385355a3998d0 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 7 Oct 2026 17:47:54 -0700 Subject: [PATCH 40/47] Reactor: component rerenders use the owning host's dispatcher; window anchor survives retarget - A component's rerender callback marshals onto its reconciler's owning dispatcher (OwningDispatcher, renamed from DiagnosticsDispatcher), not the process-wide ReactorApp.UIDispatcher, which can be another UI thread's. Falls back to the process-wide one when no host set it (selftest with a shut-down foreign process dispatcher, mutation-checked). - ReactorHost keeps the window content (e.g. the overlay wrapper) as the root's anchor after a ContentTarget is requested, until a render publishes there (selftest, mutation-checked). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.Diagnostics.cs | 12 +++-- src/Reactor/Core/Reconciler.cs | 14 +++--- src/Reactor/Hosting/ReactorHost.cs | 4 +- src/Reactor/Hosting/ReactorHostControl.cs | 2 +- .../Fixtures/ComponentInspectionFixtures.cs | 48 ++++++++++++++++--- 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 147ed53ea..74fb9df7c 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -26,13 +26,15 @@ public sealed partial class Reconciler /// /// The owning host's UI dispatcher, set alongside . - /// Hosts can live on different UI threads, so a lookup skips 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. + /// 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? DiagnosticsDispatcher { get; set; } + internal Microsoft.UI.Dispatching.DispatcherQueue? OwningDispatcher { get; set; } - private bool IsInspectableFromThisThread => DiagnosticsDispatcher is { HasThreadAccess: true }; + private bool IsInspectableFromThisThread => OwningDispatcher is { HasThreadAccess: true }; /// The reconcilers of live hosts that the calling thread may inspect. private static IEnumerable InspectableReconcilers() diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index a511970bd..68fb5edf0 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -2511,7 +2511,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 () => { @@ -2527,11 +2527,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) { diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 56649c02d..38dcb93c0 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -206,7 +206,7 @@ public ReactorWindow? OwningWindow var published = _claimedContentTarget as WinUI.Border; if (published is not null && !OwnsContentTarget(published)) published = null; // taken over by another host UIElement? container = published - ?? (ContentTarget is null && _claimedContentTarget is null && !_windowClosed ? _window.Content as UIElement : null); + ?? (_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 @@ -277,7 +277,7 @@ public ReactorHost(Window window, ILogger? logger = null) _window = window; _backdropApplier = new BackdropApplier(window); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - _reconciler.DiagnosticsDispatcher = _dispatcherQueue; + _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 diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 99a263085..ce601ca21 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -227,7 +227,7 @@ public ReactorHostControl(Component? component = null, ILogger? logger = null) _reconciler.DiagnosticsRootResolver = ResolveDiagnosticsRoot; _reconciler.RenderErrorHandlerProvider = () => EffectiveRenderErrorHandler; _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - _reconciler.DiagnosticsDispatcher = _dispatcherQueue; + _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 diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 6c32fdc51..7ebb90fd9 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -96,10 +96,10 @@ public override async Task RunAsync() 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.DiagnosticsDispatcher; - host.Reconciler.DiagnosticsDispatcher = null; + var recorded = host.Reconciler.OwningDispatcher; + host.Reconciler.OwningDispatcher = null; H.Check("CompInspect_UncapturedReconcilerNotInspected", ReactorDiagnostics.DescribeComponent(wrapper) is null); - host.Reconciler.DiagnosticsDispatcher = recorded; + host.Reconciler.OwningDispatcher = recorded; var counter = ReactorDiagnostics.DescribeComponent(wrapper); H.Check("CompInspect_ClassDescribed", counter is { Name: "Counter", Kind: "class", IsRoot: false }); @@ -126,6 +126,24 @@ counter 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); @@ -230,16 +248,16 @@ static bool IsRoot(ComponentSnapshot? s) => // 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.DiagnosticsDispatcher; + var ownDispatcher = host.Reconciler.OwningDispatcher; var foreignThread = Microsoft.UI.Dispatching.DispatcherQueueController.CreateOnDedicatedThread(); try { - host.Reconciler.DiagnosticsDispatcher = foreignThread.DispatcherQueue; + host.Reconciler.OwningDispatcher = foreignThread.DispatcherQueue; H.Check("RootAnchor_ForeignThreadReconcilerSkipped", ReactorDiagnostics.DescribeComponent(target) is null); } finally { - host.Reconciler.DiagnosticsDispatcher = ownDispatcher; + host.Reconciler.OwningDispatcher = ownDispatcher; await foreignThread.ShutdownQueueAsync(); } H.Check("RootAnchor_OwnThreadReconcilerRead", IsRoot(ReactorDiagnostics.DescribeComponent(target))); @@ -412,6 +430,24 @@ await Harness.WaitFor(() => bare.CurrentControl is not null && ReferenceEquals(w 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 { From dae7a67112f489aa764b2b3b9c7aa8e2353b1255 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 14 Oct 2026 11:15:07 -0700 Subject: [PATCH 41/47] Diagnostics: describe the rendered-root lifecycle accurately in comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.Diagnostics.cs | 8 +++++--- src/Reactor/Hosting/ReactorHost.cs | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index 74fb9df7c..e9e18ae54 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -103,9 +103,11 @@ internal readonly record struct RootComponentSource( Func IsAlive); /// -/// The root that produced a host's current control, published by the host in the same step -/// that publishes the control. Mount swaps the requested root before the queued render -/// replaces the control, so diagnostics resolve against this rather than the requested root. +/// 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 { diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 4c967ee34..7fffd1121 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -228,8 +228,9 @@ public ReactorWindow? OwningWindow || (ours && (ReferenceEquals(element, container) || ReferenceEquals(element, installed))); if (!isAnchor) return null; - // The root that produced the displayed control, not the requested one: after Mount and - // before its queued render, the old control still shows the previous root. + // 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( From 49c642c51e7cde6c1f172f1143a54a58e2cb0c17 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 14 Oct 2026 11:39:35 -0700 Subject: [PATCH 42/47] Judge records over hand-written PrintMembers; keep occupied containers - Generated-record exemption now requires a compiler-generated PrintMembers on every record in the base chain, so a hand-written base.PrintMembers that prints a secret under another name is judged by its own text. - An Empty() render only claims a ContentTarget that is actually empty; an occupied container keeps resolving to the host whose content it shows. - Unit test + RootAnchor selftest, both mutation-checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 22 ++++++++++++--- src/Reactor/Hosting/ReactorHost.cs | 5 ++-- .../Fixtures/ComponentInspectionFixtures.cs | 19 +++++++++++++ .../ReactorDiagnosticsComponentsTests.cs | 27 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index da20dba50..63fb9c49f 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -400,6 +400,8 @@ private static bool HasOwnToString(Type type) // ToString or PrintMembers — or metadata trimmed away — fails this, and the own-text check runs. [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Diagnostics-only probe; if trimmed, the object is judged by its own text, which can only redact more.")] + [UnconditionalSuppressMessage("Trimming", "IL2075", + Justification = "Diagnostics-only walk of the record base chain; trimmed metadata fails the probe, which can only redact more.")] private static bool HasGeneratedRecordText(Type type) { const global::System.Reflection.BindingFlags Any = @@ -410,10 +412,22 @@ private static bool HasGeneratedRecordText(Type type) if (type.GetFields(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance).Length > 0) return false; var toString = type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes); - var printMembers = type.GetMethod("PrintMembers", Any, null, new[] { typeof(global::System.Text.StringBuilder) }, null); - return toString is not null && printMembers is not null - && toString.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false) - && printMembers.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false); + if (toString is null || !toString.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) + return false; + // A derived record's generated PrintMembers calls base.PrintMembers, so every record in the + // chain must print through a generated PrintMembers declared on its own type; a hand-written + // one anywhere up the chain can print a secret under another member's name. + for (var t = type; t is not null && t != typeof(object); t = t.BaseType) + { + var printMembers = t.GetMethod("PrintMembers", Any | global::System.Reflection.BindingFlags.DeclaredOnly, + null, new[] { typeof(global::System.Text.StringBuilder) }, null); + if (printMembers is null + || !printMembers.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) + return false; + if (t.BaseType is { } baseType && baseType.GetMethod("$", Any) is null) + break; // reached the root record (its base is a non-record class) + } + return true; } // A collection whose ToString() is object's (prints the type name) is summarised by count; a diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 7fffd1121..d5636c568 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -1180,8 +1180,9 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _currentTree = newTree; _renderedRoot = passRoot; // An Empty() render may write nothing (the container was already empty), yet the - // container now shows this root: claim it alongside the published root. - if (newControl is null && ContentTarget is { } emptyTarget && !OwnsContentTarget(emptyTarget)) + // 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. diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 016b4085d..1980bdb76 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -423,6 +423,25 @@ emptyHost.CurrentControl is null 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))); + } + // 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; diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 962ed62e4..5b4f97cea 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -545,6 +545,33 @@ private sealed record FieldRecord(string Value, string Stored) public readonly string AdminPassword = Stored; } + [Fact] + public void Props_ADerivedRecordOverAHandWrittenBase_IsJudgedByItsText_ADerivedGeneratedOneListsMembers() + { + var overHandWritten = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new DerivedOverHandWritten("hunter2", 1)) }; + var row = Assert.Single(ComponentHandle.FromNode(overHandWritten, () => true).Describe().Props); + Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); + + var overGenerated = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new DerivedOverGenerated("a", 2)) }; + var rows = ComponentHandle.FromNode(overGenerated, () => true).Describe().Props; + Assert.Equal(new[] { "Extra", "Name" }, rows.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal)); + } + + private record HandWrittenBase(string Value) + { + protected virtual bool PrintMembers(global::System.Text.StringBuilder builder) + { + builder.Append("Password = ").Append(Value); + return true; + } + } + + private sealed record DerivedOverHandWritten(string Value, int Extra) : HandWrittenBase(Value); + + private record GeneratedBase(string Name); + + private sealed record DerivedOverGenerated(string Name, int Extra) : GeneratedBase(Name); + private sealed record HandWrittenRecord(string Value) { public override string ToString() => "ApiKey: " + Value; From 55237b1f51cef0e310c0f85264fa9e206e04fa6c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 14 Oct 2026 12:02:42 -0700 Subject: [PATCH 43/47] Summarise collections by count in applied-property text FormatPlain (GetAppliedProperties) now routes non-string collections such as DescribedByRefs/FlowsToRefs/FlowsFromRefs through the same count summary Format uses. It never calls their ToString() or enumerator on the UI thread. Adds a mutation-checked regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 25 +++++++++++++------ .../Diagnostics/DiagnosticTextTests.cs | 22 ++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 63fb9c49f..2d84e2ccb 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -145,14 +145,7 @@ internal static (string Text, bool Redacted) Format(string name, Type? declared, return (Redacted, true); if (value is not null && IsListLike(value)) - { - // 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})", false); - } + return (CollectionSummary(value, declared), false); if (value is string s) { @@ -174,6 +167,11 @@ internal static (string Text, bool Redacted) FormatPlain(string name, object? va if (IsSecretName(name) && value is not null and not Delegate) return (Redacted, true); + // 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), false); + string text; switch (value) { @@ -437,6 +435,17 @@ private static bool HasGeneratedRecordText(Type type) // 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 diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index 4da8361ff..ee3d89e32 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -304,6 +304,28 @@ public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() // ── Parsing ───────────────────────────────────────────────────── + private sealed class SelfFormattingRefs : IReadOnlyList + { + public bool Touched; + public int Count => 2; + public global::Microsoft.UI.Reactor.Input.ElementRef this[int index] { get { Touched = true; throw new InvalidOperationException(); } } + public IEnumerator GetEnumerator() { Touched = true; throw new InvalidOperationException(); } + global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + public override string ToString() { Touched = true; return "ApiKey: hunter2"; } + } + + [Fact] + public void FormatPlain_SummarisesCollectionsByCount_WithoutCallingTheirToStringOrEnumerator() + { + var refs = new SelfFormattingRefs(); + Assert.Equal(("SelfFormattingRefs (2 items)", false), DiagnosticText.FormatPlain("DescribedByRefs", refs)); + Assert.False(refs.Touched); + var array = new[] { new global::Microsoft.UI.Reactor.Input.ElementRef() }; + Assert.Equal(" (1 item)", DiagnosticText.FormatPlain("FlowsToRefs", array).Text[^9..]); + } + + // ── Parsing ───────────────────────────────────────────────────── + public static IEnumerable Parsed() => [ ["42", typeof(int), 42], From ec1692e1d5b5511330cf94d7cda6aa9c2877fc3a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 14 Oct 2026 12:26:59 -0700 Subject: [PATCH 44/47] Address review: skip redundant claims, document root identity - ClaimContentTarget returns early when this host already owns the target, so repeat renders allocate nothing. - Document why SameRootAs compares identity, not the render delegate: every Mount allocates a fresh Component/FuncContext. - Clarify that only an app name equal to the derived (possibly shortened) default is indistinguishable from it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Diagnostics/AppliedModifierMap.cs | 3 ++- src/Reactor/Core/Reconciler.Diagnostics.cs | 3 +++ src/Reactor/Hosting/ReactorHost.cs | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs index a62cb915e..1d2e4afb3 100644 --- a/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs +++ b/src/Reactor/Core/Diagnostics/AppliedModifierMap.cs @@ -210,7 +210,8 @@ internal static IReadOnlyList DescribeChain(IReadOnlyListReported 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 - /// caption is indistinguishable from the default and is reported as the default. + /// 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) { diff --git a/src/Reactor/Core/Reconciler.Diagnostics.cs b/src/Reactor/Core/Reconciler.Diagnostics.cs index e9e18ae54..5beb79c31 100644 --- a/src/Reactor/Core/Reconciler.Diagnostics.cs +++ b/src/Reactor/Core/Reconciler.Diagnostics.cs @@ -123,6 +123,9 @@ internal RenderedRoot(Component? component, RenderContext? funcContext, Delegate 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/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index d5636c568..c2b30745b 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -698,6 +698,8 @@ internal void Mount(Func renderFunc, SourceLocation? mou 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)) From c08e6d3d8ea7505b725e8deb6f245d977113f1a8 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 11:47:42 -0700 Subject: [PATCH 45/47] Publish a root whose render returns null for diagnostics Both hosts returned before publishing the pass's root when Render() returned null, so a newly mounted null-rendering root (live hooks and effects) was unreachable by DescribeComponent/TrySetState/Rerender. Publish it on the null path too (ReactorHost also claims an empty ContentTarget, as for Empty()). Selftests for both hosts, mutation-checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Hosting/ReactorHost.cs | 9 ++++++ src/Reactor/Hosting/ReactorHostControl.cs | 4 +++ .../Fixtures/ComponentInspectionFixtures.cs | 30 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 9a41e867b..38d4a63fc 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -1077,6 +1077,15 @@ 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. + if (!passRoot.IsEmpty) + { + _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; diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 2f450568f..f68e3b5a9 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -975,6 +975,10 @@ 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. + if (!passRoot.IsEmpty) + _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; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 1980bdb76..e9c6048d3 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -442,6 +442,24 @@ emptyHost.CurrentControl is null 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; @@ -554,6 +572,18 @@ await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is 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\"" }] }); + hostControl.Dispose(); H.Check("RootAnchor_DisposedHostControlNotDescribed", ReactorDiagnostics.DescribeComponent(hostControl) is null); } From 3f62f757b58ad82380b8b9bacde5d162b743e953 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 12:31:21 -0700 Subject: [PATCH 46/47] Keep error fallbacks unattributed on null retries; tolerate byref-like props - A root that shows an error fallback and then re-renders to null keeps the fallback on screen. Both hosts now track that a fallback is shown and skip publishing the root on the null path while it is. - PropRows reports a byref-like member (ReadOnlyMemory.Span) as "" instead of failing the whole description, and also catches NotSupportedException from reflection reads. - Throw -> null-return selftests for both hosts, plus a byref-like props unit test, all mutation-checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Core/Diagnostics/DiagnosticText.cs | 12 ++++-- src/Reactor/Hosting/ReactorHost.cs | 7 +++- src/Reactor/Hosting/ReactorHostControl.cs | 7 +++- .../Fixtures/ComponentInspectionFixtures.cs | 41 +++++++++++++++++++ .../ReactorDiagnosticsComponentsTests.cs | 10 +++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index 2d84e2ccb..af9ed9ef7 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -356,12 +356,16 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul object? value; try { - value = property.GetValue(props); + // 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 (global::System.Reflection.TargetInvocationException) + catch (Exception ex) when (ex is global::System.Reflection.TargetInvocationException or NotSupportedException) { - // A throwing getter still gets its row, so the member is visible; the exception - // text is not shown because it can carry the value. + // 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)); diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 38d4a63fc..6a0d75914 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -50,6 +50,8 @@ public sealed class ReactorHost : IDisposable, Core.Diagnostics.IReactorDiagnost 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 @@ -1080,7 +1082,8 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) // 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. - if (!passRoot.IsEmpty) + // 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)) @@ -1210,6 +1213,7 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _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. @@ -1611,6 +1615,7 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep _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.cs b/src/Reactor/Hosting/ReactorHostControl.cs index f68e3b5a9..c25f8b2f9 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -76,6 +76,8 @@ public sealed partial class ReactorHostControl : ContentControl, IDisposable, Co 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 @@ -977,7 +979,8 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) 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. - if (!passRoot.IsEmpty) + // 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. @@ -1061,6 +1064,7 @@ 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; @@ -1292,6 +1296,7 @@ private void SetErrorContent(UIElement? errorPanel, Element? errorTree, bool rep _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) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index e9c6048d3..136bde17a 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -337,12 +337,15 @@ await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child // 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(); @@ -359,6 +362,17 @@ await Harness.WaitFor(() => H.FindControl(t => t.Text == "shell-child 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); @@ -584,6 +598,33 @@ await Harness.WaitFor(() => ReactorDiagnostics.DescribeComponent(hostControl) is 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); } diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 5b4f97cea..252b806e5 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -640,6 +640,16 @@ public void Props_AThrowingGetterIsReportedUnavailable_NotFatal() }, props); } + [Fact] + public void Props_WithAByRefLikeMember_ReportItUnavailable_AndStillListTheRest() + { + // ReadOnlyMemory.Span cannot be read through reflection (it cannot be boxed). + var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), "hello".AsMemory()) }; + var rows = ComponentHandle.FromNode(node, () => true).Describe().Props; + Assert.Contains(rows, r => r.Name == "Span" && r.Value == ""); + Assert.Contains(rows, r => r.Name == "Length" && r.Value == "5"); + } + [Fact] public void Props_WithNoReadableMembers_NameTheTypeAndNeverItsToString() { From 3cdce464185149823550314a3d5ba313ba672a0a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 14:12:13 -0700 Subject: [PATCH 47/47] Drop secret redaction from component diagnostics Per review discussion: no mainstream inspector redacts (React DevTools, Reactor's own reactor.state tool), the inspector already runs in-process, and name/text heuristics both miss and over-hide. Values are now shown as they are; an inspector that forwards them somewhere a secret must not appear decides what to hide. - DiagnosticValue loses its Redacted member (public API; index regenerated). - DiagnosticText: remove the secret-name/type checks, the labelled-secret text regex, the record PrintMembers probe and SecretValue. Format and FormatPlain now return text. - TrySetState no longer refuses secret-typed hooks or secret-looking text. - Tests, selftest, source-mapping guide and CHANGELOG updated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- docs/_pipeline/templates/source-mapping.md.dt | 12 +- docs/guide/source-mapping.md | 12 +- .../reactor-dsl/references/reactor.api.txt | 5 +- skills/reactor.api.txt | 5 +- .../Core/Diagnostics/DiagnosticText.cs | 171 +++-------------- .../ReactorDiagnostics.Components.cs | 52 ++---- .../Fixtures/ComponentInspectionFixtures.cs | 11 +- .../Diagnostics/DiagnosticTextTests.cs | 173 ++++-------------- .../ReactorDiagnosticsComponentsTests.cs | 164 ++++------------- 10 files changed, 121 insertions(+), 488 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 806009bb3..8721d9ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,8 @@ Conventions for contributors: 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 — with secrets - redacted; a resolved reference edge carries its mounted target control. Reads + 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 diff --git a/docs/_pipeline/templates/source-mapping.md.dt b/docs/_pipeline/templates/source-mapping.md.dt index 721383757..ff0ed2573 100644 --- a/docs/_pipeline/templates/source-mapping.md.dt +++ b/docs/_pipeline/templates/source-mapping.md.dt @@ -337,14 +337,10 @@ edge's `Target`, the mounted control it points at. 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)`), long values are cut at 200 characters, -and secrets — a `SecureString`; a type, collection element type or member name -ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or -`ConnectionString`; an object whose own text, or a string, names such a member -with a value (`AccessToken=…`, a record with a `Password` property) — are -reported as -`` and are never editable; nor is a value written that would read back -redacted. +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 diff --git a/docs/guide/source-mapping.md b/docs/guide/source-mapping.md index 2b5af3a69..f1ebf1c31 100644 --- a/docs/guide/source-mapping.md +++ b/docs/guide/source-mapping.md @@ -420,14 +420,10 @@ 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)`), long values are cut at 200 characters, -and secrets — a `SecureString`; a type, collection element type or member name -ending in `Password`, `Secret`, `Credential`, `Token`, `ApiKey`, `PrivateKey` or -`ConnectionString`; an object whose own text, or a string, names such a member -with a value (`AccessToken=…`, a record with a `Password` property) — are -reported as -`` and are never editable; nor is a value written that would read back -redacted. +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 diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index 730fe3ad7..365a1e005 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -1978,16 +1978,15 @@ GetHashCode() → int ToString() → string ### record DiagnosticValue -new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool Redacted, bool MigratedByHotReload) +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool MigratedByHotReload) bool Editable { get; init; } bool MigratedByHotReload { get; init; } -bool Redacted { 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 Redacted, ref bool MigratedByHotReload) → void +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 diff --git a/skills/reactor.api.txt b/skills/reactor.api.txt index 730fe3ad7..365a1e005 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -1978,16 +1978,15 @@ GetHashCode() → int ToString() → string ### record DiagnosticValue -new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool Redacted, bool MigratedByHotReload) +new(int Index, string Name, string Kind, string Type, string Value, bool Editable, bool MigratedByHotReload) bool Editable { get; init; } bool MigratedByHotReload { get; init; } -bool Redacted { 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 Redacted, ref bool MigratedByHotReload) → void +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 diff --git a/src/Reactor/Core/Diagnostics/DiagnosticText.cs b/src/Reactor/Core/Diagnostics/DiagnosticText.cs index af9ed9ef7..51802b585 100644 --- a/src/Reactor/Core/Diagnostics/DiagnosticText.cs +++ b/src/Reactor/Core/Diagnostics/DiagnosticText.cs @@ -6,14 +6,13 @@ namespace Microsoft.UI.Reactor.Core.Diagnostics; /// /// How turns live values into text and text back into values: -/// formatting (quoted strings, summarised collections, truncation), secret redaction 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. Mirrors the winapp DevTools agent's -/// ReactorComponents / ReactorElementProps rules so its output is unchanged. +/// 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 string Redacted = ""; internal const int MaxValueLength = 200; /// int, int?, List<TaskItem>: the type as C# reads it. @@ -82,95 +81,29 @@ internal static bool IsEditable(Type? type) type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid); } - /// Whether a value of this type is a secret: a SecureString or a password-, secret- or credential-like type. - internal static bool IsSecretType(Type? type) - { - if (type is null) return false; - type = Nullable.GetUnderlyingType(type) ?? type; - return type.FullName == "System.Security.SecureString" || IsSecretName(TypeName(type)); - } - - /// - /// , or a collection of secrets: an array whose element type, or a - /// generic type with a type argument, that is a secret (List<UserCredential>). - /// - internal static bool HoldsSecretType(Type? type) - { - if (type is null) return false; - if (IsSecretType(type)) return true; - if (type.IsArray && type.GetElementType() is { } element) return HoldsSecretType(element); - if (type.IsGenericType) - { - foreach (var argument in type.GetGenericArguments()) - if (HoldsSecretType(argument)) return true; - } - return false; - } - - /// - /// Whether a member or type name says its value is a secret: it ends in Password, - /// Secret, Credential, Token, ApiKey, PrivateKey or - /// ConnectionString (any case). A bare Key is not enough — WinUI's - /// AccessKey is an ordinary property. - /// - internal static bool IsSecretName(string? name) - { - if (name is not { Length: > 0 }) return false; - foreach (var suffix in s_secretSuffixes) - if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - private static readonly string[] s_secretSuffixes = - ["Password", "Secret", "Credential", "Token", "ApiKey", "PrivateKey", "ConnectionString"]; - - // A member of a secret name followed by a value, inside an object's own text or a string: - // "Password = …" in a record's compiler-generated ToString(), "ApiSecret: …" in a hand-written - // one, "AccessToken=…" in a connection-style string, "\"Password\":\"…\"" in JSON. A bare label - // ("Password:") or an empty or whitespace-only quoted value has nothing after the separator and - // does not match. Inside quotes any non-space character is a value (",hunter2" included); - // unquoted, a delimiter (",", ";", "}", "]") ends the label instead. - private static readonly global::System.Text.RegularExpressions.Regex s_secretMemberInText = new( - @"\b\w*(?:Password|Secret|Credential|Token|ApiKey|PrivateKey|ConnectionString)[""']?\s*[=:]\s*(?:""\s*[^""\s]|'\s*[^'\s]|[^\s,;}\]""'])", - global::System.Text.RegularExpressions.RegexOptions.IgnoreCase | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant); - /// - /// A component value (prop, hook, context) as shown to the developer: redacted when its name - /// or type says it is a secret, collections summarised, strings quoted, long text cut. + /// A component value (prop, hook, context) as shown to the developer: collections + /// summarised, strings quoted, long text cut. /// - internal static (string Text, bool Redacted) Format(string name, Type? declared, object? value) + internal static string Format(Type? declared, object? value) { - if (value is SecretValue) return (Redacted, true); - if (value is not null && (IsSecretName(name) || HoldsSecretType(declared) || HoldsSecretType(value.GetType()))) - return (Redacted, true); - if (value is not null && IsListLike(value)) - return (CollectionSummary(value, declared), false); - + return CollectionSummary(value, declared); if (value is string s) - { - // A string can carry a labelled secret itself ("AccessToken=…", a connection string). - if (s_secretMemberInText.IsMatch(s)) return (Redacted, true); - return (Quote(s), false); - } - - return FormatPlain(name, value); + return Quote(s); + return FormatPlain(value); } /// - /// A value as an element record or modifier carries it: unquoted, redacted when the name says - /// it is a secret or when the value's own text shows a secret-named member, long text cut. - /// Used for applied properties. + /// A value as an element record or modifier carries it: unquoted, long text cut. Used for + /// applied properties. /// - internal static (string Text, bool Redacted) FormatPlain(string name, object? value) + internal static string FormatPlain(object? value) { - if (IsSecretName(name) && value is not null and not Delegate) - return (Redacted, true); - // 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), false); + return CollectionSummary(value, null); string text; switch (value) @@ -194,11 +127,7 @@ internal static (string Text, bool Redacted) FormatPlain(string name, object? va text = OwnText(value, value.ToString); break; } - // Any text — a string, a formattable value, an object's own ToString() (a record prints - // its members) — can carry a labelled secret ("AccessToken=…", "Password = …"); withhold - // the whole value rather than leak it. - if (value is not null && s_secretMemberInText.IsMatch(text)) return (Redacted, true); - return (Truncate(text), false); + return Truncate(text); } /// @@ -322,7 +251,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul [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(), which could print a secret member), so it degrades rather than fails.")] + "(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 @@ -332,21 +261,12 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul ? 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) or a - // secret-bearing props type stays one row, so Format shows or redacts it whole instead of - // listing members (a Guid's none, a DateTime's Ticks/Kind/…, a SessionToken's Value). - if (IsScalar(type) || props is string or Enum || IsListLike(props) - || HoldsSecretType(declared) || HoldsSecretType(type)) + // 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) }; - // A hand-written ToString() that labels a secret ("ApiKey: …") marks the whole object as - // secret, the same rule Format applies to a value's own text, so its members are not listed - // one by one. A record's compiler-generated text only lists its members, which are already - // redacted row by row, so records are not judged by it. - if (HasOwnToString(type) && !HasGeneratedRecordText(type) - && s_secretMemberInText.IsMatch(OwnText(props, props.ToString))) - return new[] { ("Props", declared ?? type, (object?)SecretValue.Instance) }; - 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) @@ -371,7 +291,7 @@ internal static bool TryParse(string text, Type type, out object? value, [NotNul 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(), which for a record would print every member, secrets included. + // 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; @@ -383,55 +303,6 @@ internal sealed class OpaqueValue(string text) public override string ToString() => text; } - /// Stands in for a value withheld as a secret; reports it redacted. - internal sealed class SecretValue - { - internal static readonly SecretValue Instance = new(); - private SecretValue() { } - public override string ToString() => Redacted; - } - - [UnconditionalSuppressMessage("Trimming", "IL2070", - Justification = "Diagnostics-only probe for a ToString override; trimmed metadata reports none, so no own-text check runs.")] - private static bool HasOwnToString(Type type) - => type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes) is { } m && m.DeclaringType != typeof(object) - && m.DeclaringType != typeof(ValueType); - - // A record whose text is entirely compiler-generated: ToString and PrintMembers both carry - // [CompilerGenerated], so the text only lists members (redacted row by row). A hand-written - // ToString or PrintMembers — or metadata trimmed away — fails this, and the own-text check runs. - [UnconditionalSuppressMessage("Trimming", "IL2070", - Justification = "Diagnostics-only probe; if trimmed, the object is judged by its own text, which can only redact more.")] - [UnconditionalSuppressMessage("Trimming", "IL2075", - Justification = "Diagnostics-only walk of the record base chain; trimmed metadata fails the probe, which can only redact more.")] - private static bool HasGeneratedRecordText(Type type) - { - const global::System.Reflection.BindingFlags Any = - global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance; - if (type.GetMethod("$", Any) is null) return false; - // Generated text also prints public fields, which PropRows does not list, so a record with - // any is judged by its own text. - if (type.GetFields(global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance).Length > 0) - return false; - var toString = type.GetMethod(nameof(ToString), global::System.Type.EmptyTypes); - if (toString is null || !toString.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) - return false; - // A derived record's generated PrintMembers calls base.PrintMembers, so every record in the - // chain must print through a generated PrintMembers declared on its own type; a hand-written - // one anywhere up the chain can print a secret under another member's name. - for (var t = type; t is not null && t != typeof(object); t = t.BaseType) - { - var printMembers = t.GetMethod("PrintMembers", Any | global::System.Reflection.BindingFlags.DeclaredOnly, - null, new[] { typeof(global::System.Text.StringBuilder) }, null); - if (printMembers is null - || !printMembers.IsDefined(typeof(global::System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) - return false; - if (t.BaseType is { } baseType && baseType.GetMethod("$", Any) is null) - break; // reached the root record (its base is a non-record class) - } - return true; - } - // 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. diff --git a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs index 483a4d3a6..08b80afce 100644 --- a/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs +++ b/src/Reactor/Core/Diagnostics/ReactorDiagnostics.Components.cs @@ -42,14 +42,11 @@ public sealed record ComponentSnapshot( /// 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, secrets -/// <redacted>. Empty when the hook has no value (effects). +/// 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 that is not a secret. -/// True when was withheld because the value is a -/// secret: a SecureString; a type, collection element type or name ending in Password, -/// Secret, Credential, Token, ApiKey, PrivateKey or ConnectionString; or a string or object whose own text names such a member with a value (AccessToken=…, a record with a -/// Password property). +/// 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( @@ -59,7 +56,6 @@ public sealed record DiagnosticValue( string Type, string Value, bool Editable, - bool Redacted, bool MigratedByHotReload); /// @@ -121,7 +117,7 @@ public static partial class ReactorDiagnostics /// 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; secrets never are. + /// 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] @@ -192,7 +188,7 @@ public static IReadOnlyList GetAppliedProperties(UIElement cont } internal static AppliedProperty ToText(AppliedModifier m) - => new(m.Modifier, m.Property, DiagnosticText.FormatPlain(m.Modifier, m.Value).Text); + => new(m.Modifier, m.Property, DiagnosticText.FormatPlain(m.Value)); /// /// Every public member is UI-thread-affine: component tables are unsynchronized and @@ -320,9 +316,8 @@ internal IReadOnlyList DescribeProps() for (int i = 0; i < rows.Count; i++) { var (name, type, value) = rows[i]; - var (text, redacted) = DiagnosticText.Format(name, type, value); - values[i] = new DiagnosticValue(i, name, "prop", DiagnosticText.FriendlyTypeName(type), text, - Editable: false, redacted, MigratedByHotReload: false); + values[i] = new DiagnosticValue(i, name, "prop", DiagnosticText.FriendlyTypeName(type), + DiagnosticText.Format(type, value), Editable: false, MigratedByHotReload: false); } return values; } @@ -337,23 +332,20 @@ internal IReadOnlyList DescribeHooks(IReadOnlyList c.Index == i) is { } contextRow) { - // Already formatted for Contexts (with the context's name for redaction): reuse it, so - // one snapshot runs app formatting code once and both rows agree. - (text, redacted) = (contextRow.Value, contextRow.Redacted); + // 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, redacted) = DiagnosticText.Format("", valueType, cell.DiagnosticValue); + text = DiagnosticText.Format(valueType, cell.DiagnosticValue); } values[i] = new DiagnosticValue(i, "", HookKindText(cell.DiagnosticKind), valueType is null ? "" : DiagnosticText.FriendlyTypeName(valueType), text, - // The same type-level gate TrySetState applies, so a null secret-typed value is not - // advertised as editable either. - Editable: cell.DiagnosticCanSet && !redacted && DiagnosticText.IsEditable(valueType) && !DiagnosticText.IsSecretType(valueType), - redacted, cell.Migrated); + Editable: cell.DiagnosticCanSet && DiagnosticText.IsEditable(valueType), + cell.Migrated); } return values; } @@ -375,9 +367,9 @@ internal IReadOnlyList DescribeContexts() // 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; } - var (text, redacted) = DiagnosticText.Format(name, ctx.ValueType, value); (result ??= new()).Add(new DiagnosticValue(i, name, isDefault ? "default" : "provided", - DiagnosticText.FriendlyTypeName(ctx.ValueType), text, Editable: false, redacted, MigratedByHotReload: false)); + DiagnosticText.FriendlyTypeName(ctx.ValueType), DiagnosticText.Format(ctx.ValueType, value), + Editable: false, MigratedByHotReload: false)); } return result is null ? global::System.Array.Empty() : result; } @@ -407,23 +399,11 @@ internal bool TrySetState(int hookIndex, string text, out string? error) error = $"hook {hookIndex} of <{Name}> has no value type"; return false; } - if (DiagnosticText.IsSecretType(valueType) || DiagnosticText.Format("", valueType, cell.DiagnosticValue).Redacted) - { - error = $"hook {hookIndex} of <{Name}> holds a secret; it is not written from diagnostics"; - return false; - } if (!DiagnosticText.TryParse(text, valueType, out var parsed, out var parseError)) { error = parseError; return false; } - // The same rule that redacts a read refuses a write, so a value the next snapshot would - // hide (a string such as "AccessToken=…") is never written from diagnostics either. - if (DiagnosticText.Format("", valueType, parsed).Redacted) - { - error = $"the value for hook {hookIndex} of <{Name}> looks like a secret; it is not written from diagnostics"; - return false; - } if (!_context.TrySetHookValueForDiagnostics(hookIndex, parsed)) { error = $"hook {hookIndex} of <{Name}> refused the value"; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs index 136bde17a..6a8024f0f 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ComponentInspectionFixtures.cs @@ -104,17 +104,14 @@ public override async Task RunAsync() 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, or — when the record probe is trimmed too — one - // redacted row judged by the record's own text. Either way the secret never leaks. + // "(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", Redacted: false } - && counter.Props[1] is { Name: "AdminPassword", Value: "", Redacted: true }; - var opaqueProps = counter is { Props: [{ Name: "Props", Value: "CounterProps (members unavailable)" }] } - || counter is { Props: [{ Name: "Props", Value: "", Redacted: true }] }; + && 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 - && !counter.Props.Any(p => p.Value.Contains("hunter2", StringComparison.Ordinal)) && (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 }]); diff --git a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs index ee3d89e32..9fbab2b59 100644 --- a/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs +++ b/tests/Reactor.Tests/Diagnostics/DiagnosticTextTests.cs @@ -7,7 +7,7 @@ namespace Microsoft.UI.Reactor.Tests.Diagnostics; /// -/// Formatting, redaction and parsing rules of — the text every +/// Formatting and parsing rules of — the text every /// value goes through. These pin the winapp DevTools agent's /// behaviour, which this code replaces. /// @@ -37,23 +37,23 @@ public void FriendlyTypeName_SpellsTypesAsCSharp(Type type, string expected) [Fact] public void Format_QuotesAndEscapesStrings() { - Assert.Equal("\"a \\\"b\\\"\\n\\\\c\"", DiagnosticText.Format("", typeof(string), "a \"b\"\n\\c").Text); + Assert.Equal("\"a \\\"b\\\"\\n\\\\c\"", DiagnosticText.Format(typeof(string), "a \"b\"\n\\c")); } // A comma-decimal culture discriminates: invariant output ("1.5") differs from de-DE ("1,5"). [CulturedFact(new[] { "de-DE" })] public void Format_PrimitivesUseInvariantCulture() { - Assert.Equal("1.5", DiagnosticText.Format("", typeof(double), 1.5).Text); - Assert.Equal("True", DiagnosticText.Format("", typeof(bool), true).Text); - Assert.Equal("null", DiagnosticText.Format("", typeof(string), null).Text); + Assert.Equal("1.5", DiagnosticText.Format(typeof(double), 1.5)); + Assert.Equal("True", DiagnosticText.Format(typeof(bool), true)); + Assert.Equal("null", DiagnosticText.Format(typeof(string), null)); } [Fact] public void TryCount_ReadsOnlyAPublicGetter() { Assert.Null(DiagnosticText.TryCount(new PrivateCountGetter())); - Assert.Equal("PrivateCountGetter (count unknown)", DiagnosticText.Format("", typeof(PrivateCountGetter), new PrivateCountGetter()).Text); + Assert.Equal("PrivateCountGetter (count unknown)", DiagnosticText.Format(typeof(PrivateCountGetter), new PrivateCountGetter())); } private sealed class PrivateCountGetter : IEnumerable @@ -71,7 +71,7 @@ public void Format_DatesRoundTripThroughTryParse() var local = new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.FromHours(-7)).AddTicks(89); foreach (var (type, value) in new (Type, object)[] { (typeof(DateTime), utc), (typeof(DateTimeOffset), local) }) { - var text = DiagnosticText.Format("", type, value).Text; + var text = DiagnosticText.Format(type, value); Assert.True(DiagnosticText.TryParse(text, type, out var parsed, out var error), error); Assert.Equal(value, parsed); if (parsed is DateTime d) Assert.Equal(DateTimeKind.Utc, d.Kind); @@ -82,18 +82,18 @@ public void Format_DatesRoundTripThroughTryParse() [Fact] public void Format_SummarisesCollectionsByTheCountTheyAdvertise() { - Assert.Equal("List (3 items)", DiagnosticText.Format("", typeof(List), new List { 1, 2, 3 }).Text); - Assert.Equal("int[] (2 items)", DiagnosticText.Format("", typeof(int[]), new[] { 1, 2 }).Text); + Assert.Equal("List (3 items)", DiagnosticText.Format(typeof(List), new List { 1, 2, 3 })); + Assert.Equal("int[] (2 items)", DiagnosticText.Format(typeof(int[]), new[] { 1, 2 })); // HashSet has no non-generic ICollection; its public Count is read instead. - Assert.Equal("HashSet (1 item)", DiagnosticText.Format("", typeof(HashSet), new HashSet { "a" }).Text); + Assert.Equal("HashSet (1 item)", DiagnosticText.Format(typeof(HashSet), new HashSet { "a" })); } [Fact] public void Format_NeverEnumeratesALazySequence() { var lazy = new CountingSequence(); - Assert.Equal("IEnumerable (count unknown)", DiagnosticText.Format("", typeof(IEnumerable), Lazy(5)).Text); - Assert.Equal($"{nameof(CountingSequence)} (count unknown)", DiagnosticText.Format("", typeof(CountingSequence), lazy).Text); + Assert.Equal("IEnumerable (count unknown)", DiagnosticText.Format(typeof(IEnumerable), Lazy(5))); + Assert.Equal($"{nameof(CountingSequence)} (count unknown)", DiagnosticText.Format(typeof(CountingSequence), lazy)); Assert.Equal(0, lazy.Enumerations); } @@ -106,14 +106,14 @@ private static IEnumerable Lazy(int n) public void Format_CompilerGeneratedCollectionType_IsNamedAfterTheDeclaredType() { IReadOnlyList value = [1, 2]; - Assert.Equal("IReadOnlyList (2 items)", DiagnosticText.Format("", typeof(IReadOnlyList), value).Text); + Assert.Equal("IReadOnlyList (2 items)", DiagnosticText.Format(typeof(IReadOnlyList), value)); } [Fact] public void Format_ACollectionIsSummarised_ItsOwnToStringIsNeverCalled() { var bag = new SelfFormatting(); - Assert.Equal($"{nameof(SelfFormatting)} (count unknown)", DiagnosticText.Format("", typeof(SelfFormatting), bag).Text); + Assert.Equal($"{nameof(SelfFormatting)} (count unknown)", DiagnosticText.Format(typeof(SelfFormatting), bag)); Assert.Equal(0, bag.ToStringCalls); Assert.Equal(0, bag.Enumerations); } @@ -121,7 +121,7 @@ public void Format_ACollectionIsSummarised_ItsOwnToStringIsNeverCalled() [Fact] public void Format_TruncatesLongValues() { - var text = DiagnosticText.Format("", typeof(string), new string('x', 500)).Text; + var text = DiagnosticText.Format(typeof(string), new string('x', 500)); // The payload is cut, and both quote delimiters survive. Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength) + "...\"", text); } @@ -131,9 +131,9 @@ public void Format_TruncationNeverSplitsASurrogatePair() { // 199 x's then an emoji (two UTF-16 units): the cut would land between its halves. var value = new string('x', DiagnosticText.MaxValueLength - 1) + "\U0001F600tail"; - var quoted = DiagnosticText.Format("", typeof(string), value).Text; + var quoted = DiagnosticText.Format(typeof(string), value); Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength - 1) + "...\"", quoted); - var plain = DiagnosticText.FormatPlain("Text", value).Text; + var plain = DiagnosticText.FormatPlain(value); Assert.Equal(new string('x', DiagnosticText.MaxValueLength - 1) + "...", plain); } @@ -141,74 +141,20 @@ public void Format_TruncationNeverSplitsASurrogatePair() public void Format_TruncationNeverSplitsAnEscapeSequence() { // 199 x's then a backslash: escaped, the cut would land between "\" and "\". - var text = DiagnosticText.Format("", typeof(string), new string('x', DiagnosticText.MaxValueLength - 1) + "\\tail").Text; + var text = DiagnosticText.Format(typeof(string), new string('x', DiagnosticText.MaxValueLength - 1) + "\\tail"); Assert.Equal("\"" + new string('x', DiagnosticText.MaxValueLength - 1) + "...\"", text); } - // ── Redaction ─────────────────────────────────────────────────── - - [Theory] - [InlineData("Password")] - [InlineData("adminPassword")] - [InlineData("ApiSecret")] - [InlineData("UserCredential")] - [InlineData("AccessToken")] - [InlineData("RefreshToken")] - [InlineData("ApiKey")] - [InlineData("PrivateKey")] - [InlineData("ConnectionString")] - public void Format_RedactsBySecretName(string name) - { - var (text, redacted) = DiagnosticText.Format(name, typeof(string), "hunter2"); - Assert.True(redacted); - Assert.Equal("", text); - } - - [Fact] - public void Format_RedactsBySecretType_DeclaredOrRuntime() - { - Assert.True(DiagnosticText.Format("", typeof(UserCredential), new UserCredential("u", "p")).Redacted); - Assert.True(DiagnosticText.Format("", typeof(object), new UserCredential("u", "p")).Redacted); - using var secure = new global::System.Security.SecureString(); - Assert.True(DiagnosticText.Format("", typeof(global::System.Security.SecureString), secure).Redacted); - } - - [Fact] - public void Format_DoesNotRedactNullOrOrdinaryNames() - { - Assert.False(DiagnosticText.Format("Password", typeof(string), null).Redacted); - Assert.False(DiagnosticText.Format("PasswordHint", typeof(string), "h").Redacted); - Assert.False(DiagnosticText.Format("Name", typeof(string), "n").Redacted); - // A bare "Key" is not a secret: WinUI's AccessKey is an ordinary modifier. - Assert.Equal(("S", false), DiagnosticText.FormatPlain("AccessKey", "S")); - } - - [Fact] - public void Format_RedactsSecretNamedOrSecretTypedCollections_RatherThanSummarisingThem() - { - Assert.Equal(("", true), DiagnosticText.Format("ApiSecret", typeof(List), new List { "k" })); - Assert.True(DiagnosticText.Format("", typeof(List), new List()).Redacted); - Assert.True(DiagnosticText.Format("", typeof(UserCredential[]), new UserCredential[1]).Redacted); - Assert.True(DiagnosticText.Format("", typeof(object), new Dictionary()).Redacted); - } + // ── Values are shown as they are ─────────────────────────────── private sealed record LoginForm(string User, string Password); - private sealed record Hints(string User, string PasswordHint); [Fact] - public void Format_RedactsAnObjectWhoseOwnTextShowsASecretMember() - { - // A record's ToString() prints its members — Password included. - var (text, redacted) = DiagnosticText.Format("Form", typeof(LoginForm), new LoginForm("me", "hunter2")); - Assert.True(redacted); - Assert.DoesNotContain("hunter2", text); - - Assert.Equal(("Hints { User = me, PasswordHint = pet }", false), DiagnosticText.Format("", typeof(Hints), new Hints("me", "pet"))); - } - - private sealed class HandWritten(string text) + public void Format_ShowsValuesUnredacted_HidingIsTheInspectorsCall() { - public override string ToString() => text; + Assert.Equal("\"AccessToken=hunter2\"", DiagnosticText.Format(typeof(string), "AccessToken=hunter2")); + Assert.Equal("LoginForm { User = me, Password = hunter2 }", DiagnosticText.Format(typeof(LoginForm), new LoginForm("me", "hunter2"))); + Assert.Equal("hunter2", DiagnosticText.FormatPlain("hunter2")); } private sealed class ThrowsOnToString @@ -233,77 +179,24 @@ public void CopyTo(Array array, int index) { } [Fact] public void Format_AThrowingCountIsReportedUnknown_InsteadOfFailing() { - Assert.Equal(("CountThrows (count unknown)", false), DiagnosticText.Format("", typeof(CountThrows), new CountThrows())); + Assert.Equal("CountThrows (count unknown)", DiagnosticText.Format(typeof(CountThrows), new CountThrows())); } [Fact] public void Format_AThrowingToStringShowsTheType_InsteadOfFailing() { - Assert.Equal(("ThrowsOnToString (ToString() threw InvalidOperationException)", false), - DiagnosticText.Format("", typeof(ThrowsOnToString), new ThrowsOnToString())); - Assert.Equal(("FormattableThrows (ToString() threw FormatException)", false), - DiagnosticText.FormatPlain("", new FormattableThrows())); + Assert.Equal("ThrowsOnToString (ToString() threw InvalidOperationException)", + DiagnosticText.Format(typeof(ThrowsOnToString), new ThrowsOnToString())); + Assert.Equal("FormattableThrows (ToString() threw FormatException)", + DiagnosticText.FormatPlain(new FormattableThrows())); } - [Theory] - [InlineData("ApiSecret: hunter2")] - [InlineData("user=me; AccessToken=hunter2")] - public void Format_RedactsHandWrittenTextNamingASecretMember(string text) - { - var (shown, redacted) = DiagnosticText.Format("", typeof(HandWritten), new HandWritten(text)); - Assert.True(redacted); - Assert.DoesNotContain("hunter2", shown); - } - - [Theory] - [InlineData("AccessToken=hunter2")] - [InlineData("Server=db;User Id=me;Password=hunter2;")] - [InlineData("apiKey: hunter2")] - [InlineData("{\"Password\":\"hunter2\"}")] - [InlineData("{ 'accessToken': 'hunter2' }")] - [InlineData("{\"Password\":\" hunter2\"}")] - [InlineData("{\"Password\":\",hunter2\"}")] - [InlineData("{\"Password\":\";hunter2\"}")] - public void Format_RedactsAStringCarryingALabelledSecret(string text) - { - var (shown, redacted) = DiagnosticText.Format("", typeof(string), text); - Assert.True(redacted); - Assert.DoesNotContain("hunter2", shown); - } - - [Theory] - [InlineData("Password:")] - [InlineData("Enter your Password: ")] - [InlineData("Forgot password?")] - public void Format_KeepsABareSecretLabel(string text) - => Assert.False(DiagnosticText.Format("", typeof(string), text).Redacted); - [Fact] - public void FormatPlain_DoesNotQuote_ButStillRedacts() + public void FormatPlain_DoesNotQuote() { - Assert.Equal(("Save", false), DiagnosticText.FormatPlain("AutomationName", "Save")); - Assert.Equal(("", true), DiagnosticText.FormatPlain("Password", "x")); + Assert.Equal("Save", DiagnosticText.FormatPlain("Save")); } - private sealed class FormattableSecret : IFormattable - { - public string ToString(string? format, IFormatProvider? provider) => "ApiKey: hunter2"; - } - - [Fact] - public void FormatPlain_RedactsLabelledSecretText_InEveryValueShape() - { - Assert.Equal(("", true), DiagnosticText.FormatPlain("ToolTip", "AccessToken=hunter2")); - Assert.Equal(("", true), DiagnosticText.FormatPlain("", new FormattableSecret())); - Assert.Equal(("", true), DiagnosticText.FormatPlain("", new HandWritten("Password = hunter2"))); - // A bare label is ordinary UI text, as is a JSON member with an empty value. - Assert.Equal(("Password:", false), DiagnosticText.FormatPlain("ToolTip", "Password:")); - Assert.Equal(("{\"Password\":\"\"}", false), DiagnosticText.FormatPlain("ToolTip", "{\"Password\":\"\"}")); - Assert.Equal(("{\"Password\":\" \"}", false), DiagnosticText.FormatPlain("ToolTip", "{\"Password\":\" \"}")); - } - - // ── Parsing ───────────────────────────────────────────────────── - private sealed class SelfFormattingRefs : IReadOnlyList { public bool Touched; @@ -311,17 +204,17 @@ private sealed class SelfFormattingRefs : IReadOnlyList GetEnumerator() { Touched = true; throw new InvalidOperationException(); } global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); - public override string ToString() { Touched = true; return "ApiKey: hunter2"; } + public override string ToString() { Touched = true; return "refs"; } } [Fact] public void FormatPlain_SummarisesCollectionsByCount_WithoutCallingTheirToStringOrEnumerator() { var refs = new SelfFormattingRefs(); - Assert.Equal(("SelfFormattingRefs (2 items)", false), DiagnosticText.FormatPlain("DescribedByRefs", refs)); + Assert.Equal("SelfFormattingRefs (2 items)", DiagnosticText.FormatPlain(refs)); Assert.False(refs.Touched); var array = new[] { new global::Microsoft.UI.Reactor.Input.ElementRef() }; - Assert.Equal(" (1 item)", DiagnosticText.FormatPlain("FlowsToRefs", array).Text[^9..]); + Assert.Equal(" (1 item)", DiagnosticText.FormatPlain(array)[^9..]); } // ── Parsing ───────────────────────────────────────────────────── diff --git a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs index 252b806e5..94501d54e 100644 --- a/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs +++ b/tests/Reactor.Tests/Diagnostics/ReactorDiagnosticsComponentsTests.cs @@ -61,7 +61,6 @@ public void State_DescribesEveryPrimitiveHookInCallOrder_AsText() Assert.Equal(new[] { "int", "string", "int", "int", "string", "", "string" }, hooks.Select(h => h.Type)); Assert.Equal(new[] { true, true, true, false, false, false, false }, hooks.Select(h => h.Editable)); Assert.All(hooks, h => Assert.Equal("", h.Name)); - Assert.All(hooks, h => Assert.False(h.Redacted)); } [Fact] @@ -88,17 +87,16 @@ public void State_NonTextValueIsReadableButNotEditable() } [Fact] - public void State_SecretTypeIsRedactedAndNotEditable() + public void State_ValuesAreShownUnredacted() { var r = new Renderer(); r.Begin(); r.Context.UseState(new UserCredential("me", "hunter2")); - r.Context.UseState("plain"); + r.Context.UseState("AccessToken=abc"); var hooks = ForContext(r.Context).Describe().State; - Assert.Equal(("", true, false), (hooks[0].Value, hooks[0].Redacted, hooks[0].Editable)); - Assert.DoesNotContain("hunter2", hooks[0].Value); - Assert.Equal(("\"plain\"", false, true), (hooks[1].Value, hooks[1].Redacted, hooks[1].Editable)); + Assert.Equal(("UserCredential { User = me, Password = hunter2 }", false), (hooks[0].Value, hooks[0].Editable)); + Assert.Equal(("\"AccessToken=abc\"", true), (hooks[1].Value, hooks[1].Editable)); } [Fact] @@ -193,8 +191,6 @@ public void TrySetState_RefusesWithAReason_WithoutWriting() r.Context.UseRef(5); r.Context.UseEffect(() => { }, 1); r.Context.UseState(new List()); - r.Context.UseState(new UserCredential("me", "pw")); - r.Context.UseState("Server=db;Password=hunter2"); var handle = ForContext(r.Context); AssertRefused(handle, 0, "abc", "'abc' is not a valid int"); @@ -203,9 +199,7 @@ public void TrySetState_RefusesWithAReason_WithoutWriting() AssertRefused(handle, 2, "1", "has kind 'effect'"); AssertRefused(handle, 3, "x", "List cannot be set from text"); AssertRefused(handle, 3, "null", "List cannot be set from text"); - AssertRefused(handle, 4, "x", "holds a secret"); - AssertRefused(handle, 5, "x", "holds a secret"); - AssertRefused(handle, 6, "1", "has no hook 6"); + AssertRefused(handle, 4, "1", "has no hook 4"); AssertRefused(handle, -1, "1", "has no hook -1"); Assert.Equal("1", handle.Describe().State[0].Value); @@ -220,35 +214,29 @@ private static void AssertRefused(ComponentHandle handle, int index, string text } [Fact] - public void State_ANullSecretTypedValue_IsNotAdvertisedEditable() + public void State_ANullNullableEnum_IsEditable() { var r = new Renderer(); r.Begin(); - r.Context.UseState(null); + r.Context.UseState(null); var handle = ForContext(r.Context); var hook = Assert.Single(handle.Describe().State); - Assert.Equal(("null", false, false), (hook.Value, hook.Redacted, hook.Editable)); - AssertRefused(handle, 0, "On", "holds a secret"); + Assert.Equal(("null", true), (hook.Value, hook.Editable)); + Assert.True(handle.TrySetState(0, "On", out var error), error); + Assert.Equal("On", handle.Describe().State[0].Value); } - private enum ApiSecret { Off, On } - [Fact] - public void TrySetState_RefusesAValueItsNextSnapshotWouldRedact() + public void TrySetState_WritesTextThatLooksLikeASecret() { var r = new Renderer(); r.Begin(); r.Context.UseState("plain"); var handle = ForContext(r.Context); - AssertRefused(handle, 0, "AccessToken=abc123", "looks like a secret; it is not written from diagnostics"); - - Assert.Equal("\"plain\"", handle.Describe().State[0].Value); - Assert.Equal(0, r.Rerenders); - // An ordinary value on the same hook is still written. - Assert.True(handle.TrySetState(0, "hello", out var error), error); - Assert.Equal("\"hello\"", handle.Describe().State[0].Value); + Assert.True(handle.TrySetState(0, "AccessToken=abc123", out var error), error); + Assert.Equal("\"AccessToken=abc123\"", handle.Describe().State[0].Value); } [Fact] @@ -346,13 +334,13 @@ public void Contexts_ReportProvidedAndDefaultValues() Assert.Equal(new[] { - new DiagnosticValue(1, "ThemeName", "provided", "string", "\"dark\"", false, false, false), - new DiagnosticValue(2, "Other", "default", "int", "4", false, false, false), + new DiagnosticValue(1, "ThemeName", "provided", "string", "\"dark\"", false, false), + new DiagnosticValue(2, "Other", "default", "int", "4", false, false), }, contexts); } [Fact] - public void Contexts_SecretNamedContextIsRedacted_InContextsAndState() + public void Contexts_ValuesAreShownUnredacted_InContextsAndState() { var apiSecret = new Context("", "ApiSecret"); var r = new Renderer(); @@ -361,9 +349,8 @@ public void Contexts_SecretNamedContextIsRedacted_InContextsAndState() r.Context.UseContext(apiSecret); var snapshot = ForContext(r.Context).Describe(); - Assert.True(snapshot.Contexts.Single().Redacted); - Assert.True(snapshot.State.Single().Redacted); - Assert.DoesNotContain("s3cr3t", snapshot.Contexts.Single().Value + snapshot.State.Single().Value); + Assert.Equal("\"s3cr3t\"", snapshot.Contexts.Single().Value); + Assert.Equal("\"s3cr3t\"", snapshot.State.Single().Value); } [Fact] @@ -382,9 +369,8 @@ public void Contexts_ASlotSwitchedToAnotherContext_IsDescribedByTheOneLastRead() var snapshot = ForContext(r.Context).Describe(); Assert.Equal("ApiSecret", snapshot.Contexts.Single().Name); - Assert.True(snapshot.Contexts.Single().Redacted); - Assert.True(snapshot.State.Single().Redacted); - Assert.DoesNotContain("s3cr3t", snapshot.Contexts.Single().Value + snapshot.State.Single().Value); + Assert.Equal("\"s3cr3t\"", snapshot.Contexts.Single().Value); + Assert.Equal("\"s3cr3t\"", snapshot.State.Single().Value); } private sealed class CountingText @@ -468,12 +454,12 @@ public void ClassComponent_ReportsNameKindAndLivePropsAsRows() Assert.Equal(("Counter", "class", false), (s.Name, s.Kind, s.IsRoot)); // The instance's current props win over the element's (they diverge when a memoized - // parent skips re-rendering this child); a *Password member is redacted. + // parent skips re-rendering this child). Assert.Equal(new[] { - new DiagnosticValue(0, "Start", "prop", "int", "3", false, false, false), - new DiagnosticValue(1, "Label", "prop", "string", "\"go\"", false, false, false), - new DiagnosticValue(2, "AdminPassword", "prop", "string", "", false, true, false), + new DiagnosticValue(0, "Start", "prop", "int", "3", false, false), + new DiagnosticValue(1, "Label", "prop", "string", "\"go\"", false, false), + new DiagnosticValue(2, "AdminPassword", "prop", "string", "\"pw\"", false, false), }, s.Props); } @@ -485,7 +471,7 @@ public void ScalarProps_AreOneRowNamedProps_AndGenericNamesDropArity() var s = ComponentHandle.FromNode(node, () => true).Describe(); Assert.Equal("Generic", s.Name); - Assert.Equal(new[] { new DiagnosticValue(0, "Props", "prop", "int", "7", false, false, false) }, s.Props); + Assert.Equal(new[] { new DiagnosticValue(0, "Props", "prop", "int", "7", false, false) }, s.Props); } [Fact] @@ -517,70 +503,11 @@ public void PropRows_TypedNullProps_KeepAPropsRow_PropslessHasNone() } [Fact] - public void Props_WhoseOwnTextLabelsASecret_AreOneRedactedRow_RecordsStillListMembers() + public void Props_AreListedMemberByMember_Unredacted() { - var labelled = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new ApiConfig()) }; - var row = Assert.Single(ComponentHandle.FromNode(labelled, () => true).Describe().Props); - Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); - - // A record's generated text names AdminPassword too, but its rows are redacted one by one. var record = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new AdminRecord(2, "hunter2")) }; var rows = ComponentHandle.FromNode(record, () => true).Describe().Props; - Assert.Equal(new[] { ("Step", "2", false), ("AdminPassword", "", true) }, rows.Select(r => (r.Name, r.Value, r.Redacted))); - - // A record with a hand-written ToString is judged by that text like any other object. - var handWritten = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new HandWrittenRecord("hunter2")) }; - var hwRow = Assert.Single(ComponentHandle.FromNode(handWritten, () => true).Describe().Props); - Assert.Equal(("Props", "", true), (hwRow.Name, hwRow.Value, hwRow.Redacted)); - - // Generated text also prints public fields, which are not listed as rows: a record with a - // secret field is judged by its text rather than leaking its other members. - var withField = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new FieldRecord("visible", "hunter2")) }; - var fieldRow = Assert.Single(ComponentHandle.FromNode(withField, () => true).Describe().Props); - Assert.Equal(("Props", "", true), (fieldRow.Name, fieldRow.Value, fieldRow.Redacted)); - } - - private sealed record FieldRecord(string Value, string Stored) - { - public readonly string AdminPassword = Stored; - } - - [Fact] - public void Props_ADerivedRecordOverAHandWrittenBase_IsJudgedByItsText_ADerivedGeneratedOneListsMembers() - { - var overHandWritten = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new DerivedOverHandWritten("hunter2", 1)) }; - var row = Assert.Single(ComponentHandle.FromNode(overHandWritten, () => true).Describe().Props); - Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); - - var overGenerated = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new DerivedOverGenerated("a", 2)) }; - var rows = ComponentHandle.FromNode(overGenerated, () => true).Describe().Props; - Assert.Equal(new[] { "Extra", "Name" }, rows.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal)); - } - - private record HandWrittenBase(string Value) - { - protected virtual bool PrintMembers(global::System.Text.StringBuilder builder) - { - builder.Append("Password = ").Append(Value); - return true; - } - } - - private sealed record DerivedOverHandWritten(string Value, int Extra) : HandWrittenBase(Value); - - private record GeneratedBase(string Name); - - private sealed record DerivedOverGenerated(string Name, int Extra) : GeneratedBase(Name); - - private sealed record HandWrittenRecord(string Value) - { - public override string ToString() => "ApiKey: " + Value; - } - - private sealed class ApiConfig - { - public string Value => "hunter2"; - public override string ToString() => "ApiKey: " + Value; + Assert.Equal(new[] { ("Step", "2"), ("AdminPassword", "\"hunter2\"") }, rows.Select(r => (r.Name, r.Value))); } private sealed record AdminRecord(int Step, string AdminPassword); @@ -597,16 +524,6 @@ public void PropRows_ScalarValues_AreOneRowShownByValue() } } - [Fact] - public void Props_OfASecretBearingType_AreOneRedactedRow_NotListedMemberByMember() - { - var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new SessionToken("s3cr3t")) }; - - var row = Assert.Single(ComponentHandle.FromNode(node, () => true).Describe().Props); - - Assert.Equal(("Props", "", true), (row.Name, row.Value, row.Redacted)); - } - [Fact] public void Props_SkipAPropertyWhoseGetterIsNotPublic() { @@ -614,11 +531,9 @@ public void Props_SkipAPropertyWhoseGetterIsNotPublic() var props = ComponentHandle.FromNode(node, () => true).Describe().Props; - Assert.Equal(new[] { new DiagnosticValue(0, "Visible", "prop", "string", "\"v\"", false, false, false) }, props); + Assert.Equal(new[] { new DiagnosticValue(0, "Visible", "prop", "string", "\"v\"", false, false) }, props); } - private sealed record SessionToken(string Value); - private sealed class PrivateGetterProps { public string Visible => "v"; @@ -635,8 +550,8 @@ public void Props_AThrowingGetterIsReportedUnavailable_NotFatal() Assert.Equal(new[] { - new DiagnosticValue(0, "Good", "prop", "int", "1", false, false, false), - new DiagnosticValue(1, "Bad", "prop", "int", "", false, false, false), + new DiagnosticValue(0, "Good", "prop", "int", "1", false, false), + new DiagnosticValue(1, "Bad", "prop", "int", "", false, false), }, props); } @@ -654,27 +569,14 @@ public void Props_WithAByRefLikeMember_ReportItUnavailable_AndStillListTheRest() public void Props_WithNoReadableMembers_NameTheTypeAndNeverItsToString() { // The same fallback a trimmed app hits when property metadata is gone: the object's - // own text could print a secret member, so it must not be used. + // own ToString() is arbitrary app code, so it is not used. var node = new Reconciler.ComponentNode { Component = new Plain(), Element = new ComponentElement(typeof(Plain), new FieldOnlyProps("hunter2")) }; var row = ComponentHandle.FromNode(node, () => true).Describe().Props.Single(); - Assert.Equal(new DiagnosticValue(0, "Props", "prop", "FieldOnlyProps", "FieldOnlyProps (members unavailable)", false, false, false), row); + Assert.Equal(new DiagnosticValue(0, "Props", "prop", "FieldOnlyProps", "FieldOnlyProps (members unavailable)", false, false), row); } - [Fact] - public void State_ARecordWithASecretMemberIsRedacted() - { - var r = new Renderer(); - r.Begin(); - r.Context.UseState(new LoginState("me", "hunter2")); - - var hook = Assert.Single(ForContext(r.Context).Describe().State); - Assert.Equal(("", true, false), (hook.Value, hook.Redacted, hook.Editable)); - } - - private sealed record LoginState(string User, string Password); - private static Element RenderList(RenderContext _) => new EmptyElement(); [Fact] @@ -999,5 +901,5 @@ public void AppliedModifierMap_CoversEveryModifier_OrExcludesItOnPurpose() } } -/// A credential-like state value: its type name marks it a secret. +/// A credential-like state value (shown as-is: hiding values is the inspector's call). internal sealed record UserCredential(string User, string Password);