Skip to content

Inspector diagnostics without a managed agent: ReactorSource on every control, declared names, hook names (Native AOT) - #1342

Merged
Alexandre Zollinger Chohfi (azchohfi) merged 113 commits into
mainfrom
azchohfi-sourcemap-column-and-render-event
Oct 21, 2026
Merged

Alexandre Zollinger Chohfi (azchohfi) merged 113 commits into
mainfrom
azchohfi-sourcemap-column-and-render-event

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Reactor now publishes what it knows about each control on the control itself, as an attached string property. Out-of-process WinUI inspectors (winapp DevTools, VS Live Property Explorer) can then show a Reactor app's component tree, call sites, keys, declared names and hook names through XamlDiagnostics, with no managed agent in the app. That means Native AOT apps can be inspected too, as XAML apps already are.

Builds on #1328 (call-site column, ComponentRendered, generator changes), now merged into main; this PR targets main directly.

What's new

  • ReactorDiagnostics.SourceProperty ("ReactorSource" in XamlDiagnostics): published on every realized control in diagnostics mode, including component wrappers and host roots.
    v=1|at=<path>:<line>[:<col>][|rel=<root|0>]|owner=<Comp>|element=<Kind>[|mounts=<Comp>][|root=<Comp>][|key=<k>][|name=<id>][|hooks=<i>:<name>@<line>;...]
    
    • Escaping: % becomes %25 and | becomes %7C. Readers ignore unknown fields.
    • owner / mounts / root use the same component names as RenderError / ComponentRendered.
    • hooks indices are RenderContext slots, or ? once a slot can't be known statically.
  • SourceLocation.DeclaredName: the identifier an element was assigned to, like x:Name (var title = TextBlock(…).Bold() gives title). The source-map generator records it, together with each component render's hook variables. Both go into a per-assembly table registered by a module initializer and built lazily.
  • Gating, matching XAML's XBF line info. Publishing needs both:
    • Build: the trimmable Reactor.DevtoolsSupport switch, controlled by a new MSBuild property ReactorDiagnostics. It is true in Debug by default and implies ReactorSourceMap=true. Use -p:ReactorDiagnostics=true for a Release / Native AOT devtools build. An explicit value always wins, and a project-declared Reactor.DevtoolsSupport item wins over the Debug default.
    • Launch: REACTOR_DIAGNOSTICS=1.
    • Hot-path guards are inline ReactorFeatures.DevtoolsSupported && … checks, so ILC removes all of it from a build without the switch.
  • Paths, matching XAML's ms-appx:///….
    • at= is relative to the project directory, else to the solution root (rel=root), else the file name only (rel=0).
    • Deterministic PathMap paths (/_/, /_<n>/) are kept as they are.
    • It is never an absolute developer path. Element.CallSite is unchanged.
    • Threat model §7.7 compares this with XAML: the same gating model, package-relative paths, and only same-user readers.
  • Native AOT: no call-site-only element tag.
    • In diagnostics mode, every stamped element used to be tagged (a ReactorState plus its DP) only so a managed inspector could read its call site.
    • Under Native AOT no managed agent can load (!RuntimeFeature.IsDynamicCodeSupported, cached once), so NeedsTag now skips an element whose only extra is its call site.
    • That control ends up in exactly the state an unstamped Release build leaves it in. ReactorSourceMap.GetSource returns the same SourceLocation from the published value: each cached value shape records its full location against its at= text. If two call sites share one at= text, controls published after the collision keep their tag.
    • An element keeps its tag when unmount must reach its handler through it: a stamped element whose handler tears down child slots the generic unmount walk does not reach (NavigationView pane slots, item hosts), or whose handler is hand-written rather than a descriptor.
    • A forwarding RegisterType registration keeps describing its child: the published value stays the child's, and GetSource reads it when the registration had to tag the control with itself.
    • JIT / Debug are unchanged.

Element-tag audit (Native AOT tag skip)

Only NeedsTag's call-site-only arm and the spec-010 shallow-skip refresh (RefreshCallSiteTagOnSkip) change. Everything else is untouched:

  • Callers that force a tag with SetElementTag:
    • decorators: Flyout / MenuFlyout / CommandBarFlyout, whose OnOpened / OnClosed read the tag;
    • Popup and the ContentDialog placeholder;
    • composite, FormField and validation hosts;
    • ListView / GridView / FlipView / TreeView / LazyStack, and templated item ContentControls;
    • XamlInterop;
    • DataGrid ResizeGrip and the Win2D handlers;
    • generated wrappers.
  • Tags required for callbacks, keys, behavioural extras and reference modifiers.
  • Generic readers of the tag, which only read data that keeps its own tag:
    • unmount: transitions, connected animation, interaction states / keyframes / scroll animation, reference cleanup, V1 / registered unmount dispatch;
    • keyed reorder;
    • AnimationHelper;
    • list item rebind;
    • the decorator adapter.
  • Reconciler.GetElementTag returns null for a skipped control, as it already does for any untagged display leaf.

Bug fixed while measuring

The first version kept a per-control side table (a ConditionalWeakTable keyed by the UIElement). CsWinRT wrappers are collected and re-created for the same native control, so entries vanished after GCs and re-published values dropped owner=. Measured: only 3,078 of 9,999 controls kept it. Now there is no per-control state:

  • an update re-publishes only when the call site, key, element kind or component type changed;
  • values are cached per shape, so unkeyed controls from one call site share one string.

Two other changes:

  • Source-mapped builds share one immutable call-site-only ElementExtras bucket per call site, instead of a ~150 B bucket per element.
  • ItemsRepeater row adoption carries the replacement's published value onto the realized wrapper.

Linked issue / spec

Fixes #1341
Spec 010 (source attribution). Builds on #1328.

Integration with #1318 and #1323 (both on main). Included in this PR:

Covered by RootRenderFunctionHooks_ResolveFromTheMountSite (SourceMap.Tests) and the ReactorSource_RootRenderFunctionHooks selftest check.

Test plan

  • dotnet test tests/Reactor.Tests -p:Platform=x64: 14686 total, 0 failed (64 skipped). New tests:
    • ReactorSourceFormatTests: grammar, escaping, path relativization including /_work vs /_/ and /_1/, gates, feature-switch attribute.
    • ReactorSourcePublisherTests:
      • identity rules;
      • cached Format byte-identical to the uncached build for plain / keyed / escaped / no-site / no-owner / column-0 / absolute shapes and root + hooks;
      • at= key parsing; published value → full SourceLocation; collision keeps the first site;
      • the AOT mode condition.
    • HookSlotTableTests.
    • SourceMapStaticInfoGeneratorTests: generator output for names (locals, fields, properties, assignments, expression bodies, fluent chains, unnamed inline elements) and hooks (class, generic and render-function components, custom hooks, unknown / conditional hooks).
    • ReactorDiagnosticsTargetsTests: evaluates the real Reactor.targets in both import orders.
    • ElementExtrasAllocationTests.StampedLeavesFromOneCallSiteShareTheBucket.
  • dotnet test tests/Reactor.SourceMap.Tests -p:Platform=x64: 96 / 96, including StaticInfoTests.
  • Selftests: dotnet run --project tests/Reactor.AppTests.Host -c Debug -p:Platform=x64 -- --self-test: 0 failures. New fixtures:
    • ReactorSource_PublishedOnEveryControl: off gate, untagged leaf, escaped key, owner / mounts / root, name=, hooks=, skip-republish.
    • ReactorSource_AotTagSkipKeepsGetSource: runs one tree through both tag paths and requires GetSource to agree on all 43 controls, after mount and after a re-render that moves call sites. The skip path has 11 tags instead of 30, and 19 controls resolve from the published value.
    • ReactorSource_AdoptedRowRepublished and ReactorSource_LayoutRealizedRowsHaveNoOwner: ItemsRepeater adoption, and rows realized or reused while scrolling.
  • Mutation checks. Each of these breaks makes the stated tests fail:
    • no AOT fallback, or no republish on skip: the both-paths fixture;
    • no adoption republish: the adoption fixture;
    • stale root owner during layout: the scroll fixture;
    • generator fluent-chain look-through or slot arithmetic: the generator tests;
    • identity key or cached tail: the publisher tests;
    • targets normalize rule: the targets test.
  • Trimming. dotnet publish tests/aot_trim_proof/Reactor.AotHelloWorld -c Release -r win-x64 -p:PublishAotInternal=true -p:Platform=x64, then Reactor.AotHelloWorld.TrimAssertions: 2/2. The new probes (ReactorSourcePublisher, ReactorSourceFormat, REACTOR_DIAGNOSTICS, |at=) are absent with the switch off and present with -p:ReactorDiagnostics=true.
  • dotnet build src/Reactor -c Release -p:Platform=x64: 0 warnings. I couldn't run a full Reactor.slnx Release build locally (NuGet was unreachable, so restore failed); it showed no compile warnings or errors.
  • API index regenerated; both copies are identical. diagnostics and source-mapping guides recompiled.
  • Repo pr-review skill: 3 rounds over 8 dimensions, plus a cross-check by a second model (gpt-5.5). No critical or high findings. All 9 medium/low findings were fixed and none were dismissed:
    • a /_work path and a \-rooted path were published unrelativized;
    • adoption left a stale published value;
    • a collision of published at= texts lost the first location;
    • layout-realized rows inherited a stale root owner;
    • name= / hooks= had no live coverage;
    • the agent-kit skill, the CHANGELOG and the rel= XML doc needed updates.
Performance: Native AOT and JIT Debug, 1k / 10k / 50k controls, at least 5 interleaved runs per cell

Method.

  • The benchmark app has two shapes:
    • a nested tree of 13-control component cards, every control realized;
    • a virtualized LazyVStack list.
  • It is compared against this PR's base, on the same machine. Builds were interleaved round-robin, with one warm-up run plus 5 measured runs per cell.
  • Re-render (i) changes every row's text with the same call sites. Re-render (ii) moves every row's call sites.
  • Memory is measured after a full GC.
  • Modes:
    • a: no K.
    • b: switch off (the Release default).
    • c: switch on, REACTOR_DIAGNOSTICS unset (the new Debug default).
    • d: diagnostics on. d3 is before the Native AOT tag skip, d5 after.
  • In the tables, ~ means the min–max ranges overlap (within noise) and ! means they don't.

(b) and (c) are within noise of (a) on every metric: time to first frame, mount, both re-renders, managed heap, working set, private bytes, GC counts and allocations, at every size, in AOT and Debug. The only ! entries are deterministic constants of a few KB: ≤ 0.3 % in heap / allocations, and the Debug runtime's own 16 KB allocation step, which the baseline shows too. Native AOT 50k nested:

metric a (median) b c d3 d5
time to first frame (ms) 2,118.6 ~-0.6% ~-0.0% !+25.6% !+10.4%
mount (ms) 541.8 ~+0.1% ~-1.8% !+90.3% !+30.8%
mount allocations (B) 41,932,664 ~+0.0% ~+0.0% !+54.6% !+23.7%
managed heap after mount (B) 15,285,512 ~+0.0% !+0.0% !+72.2% !+6.7%
private bytes after mount (B) 555,622,400 ~+0.2% ~+0.0% !+13.6% !+6.4%
re-render (i) reconcile (ms) 342.6 ~+0.3% ~-1.3% ~+28.8% ~+5.4%
re-render (ii) reconcile (ms) 88.7 ~-6.8% ~+7.1% !+352.2% !+279.4%

The trimmer removes K entirely when the switch is off. The (b) exe is the same size as (a) (10,126,848 bytes), with the same ILC symbol count (139,123).

Diagnostics on, per control (Native AOT, 50k nested, delta vs a):

first version + no side table, caching, shared call-site bucket + Native AOT tag skip
managed heap 559 B 221 B 20 B
private bytes 1,940 B 1,512 B 713 B
allocations at mount 1,403 B 458 B 199 B
mount time 14 µs 9.8 µs 3.3 µs
allocation per unchanged re-render 1,158 B 142 B 142 B

What remains:

  • The attached string itself costs about 59 B managed (CsWinRT's box) + ~240 B native when the value is shared, and ~290 B + ~320 B when it is unique (keyed controls). Measured by setting it directly on 50k controls.
  • The 142 B per re-render is the source-map interceptor's per-render with { CallSite } copy, which REACTOR_SOURCEMAP=1 has always had.

Out-of-process reader. winapp DevTools on 10k controls, Native AOT: attach plus two inspects stall the UI thread for ~310 ms median, vs ~270 ms with nothing published. App re-render time and allocations are unchanged after attaching. The tap gets everything with no agent (annotations: published, 9,999 elements).

Risk / breaking changes

  • Debug default. ReactorDiagnostics turns Reactor.DevtoolsSupport on in Debug for every Reactor app; the scaffolded template already did. Devtools surfaces still need their launch verb, and nothing is published without REACTOR_DIAGNOSTICS=1. Opt out with -p:ReactorDiagnostics=false. Measured within noise when not launched for inspection.
  • New public API:
    • ReactorDiagnostics.SourceProperty, ReactorDiagnostics.GetSource and ReactorDiagnostics.IsSourcePublishingEnabled;
    • SourceLocation.DeclaredName;
    • generator infrastructure ReactorSourceMap.RegisterStaticInfo and ReactorStaticInfoBuilder ([EditorBrowsable(Never)]).
  • Shared call-site bucket. In source-mapped builds, elements from one call site share one immutable ElementExtras instance. Adding any behavioural extra still copies the bucket.
  • Native AOT diagnostics mode. Reconciler.GetElementTag is null for call-site-only controls, as for any untagged display leaf. GetSource is unchanged.
  • Information exposure. In diagnostics mode, element keys, declared names and project-relative paths are readable by same-user XamlDiagnostics clients (threat model §7.7). This needs both an explicit build switch and a launch-time opt-in.

The source-map interceptor generator already knows the exact column of each
DSL call; the runtime SourceLocation only exposed file + line, so an inspector
could not tell Row(Button("a"), Button("b")) apart or anchor a comment on
the exact call.

- SourceLocation gains a non-positional int ColumnNumber { get; init; }
  (1-based, 0 = unknown) and a (FilePath, LineNumber, ColumnNumber) ctor.
  The 2-arg ctor, Deconstruct, ToString and ToShortString are unchanged and
  the struct stays 16 bytes.
- The generator stamps the column of the invoked method's name (the open
  paren's when name and paren sit on different lines, because the line
  follows the paren for [CallerLineNumber] parity), and the argument
  expression's column for argument-position stamps. Column takes part in the
  incremental-model equality so a sideways-only edit regenerates.
- Generator driver tests and live column tests (oracle = the test file's own
  text); spec 010 amendment, source-mapping guide, API index.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Inspectors (winapp devtools "highlight updates", why-did-render) need to know
when each component renders and why. The existing ComponentRenderStart/Stop
pair is Informational timing only: it skips mount and the host root, reports
just "self"/"parent", and carries no instance identity, so an overlay cannot
tell which on-screen component to flash.

- ReactorEventSource.ComponentRendered, EventId 40, Level Verbose, keywords
  Render | RenderDetail (new, 0x4000). Payload: componentName (CLR type name),
  componentId (process-unique long), reason (mount | state | props | context |
  parent | hotReload | forced), elapsedMicroseconds. Written with
  WriteEventCore (no boxing/params array). No PII: type names and fixed tokens.
- Emitted from the reconciler's mount and update paths and from both hosts'
  root render. Reasons come from the existing memo/force/self-trigger state.
- ReactorTrace.GetComponentControl(long) / TryGetComponentId(UIElement, out
  long) resolve ids to the component's wrapper (or the host's root content).
  The registry is weak, populated only while the event is enabled, and
  dropped on unmount so a pooled wrapper is never misattributed.
- Disabled cost: one EventSource.IsEnabled check per component render
  (measured ~1 ns, 0 B), plus a long on each ComponentNode.
- Unit tests for the wire contract, keyword/level gating, classification and
  registry; a selftest fixture for the reconciler wiring; diagnostics and
  perf-instrumentation docs; API index.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Prune is now exercised by expiring a weak entry through a test hook rather
than GC.Collect(), which also makes it deterministic; ColumnTests joins a
fixed relative path instead of Path.Combine with segments.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ReactorHost and ReactorHostControl now hold one RootRenderDiagnostics
(id, first-render flag, Reset/TraceRendered/TrackContent) instead of
parallel fields and methods, so the two hosts cannot drift. The
realized-replacement adoption path only touches the registry while
ComponentRendered is enabled; the next traced render re-tracks the id.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- A component whose Render() throws inside an ErrorBoundary now reports the
  render (mount and update paths) before the exception reaches the boundary.
- A host pass that fails after the root rendered maps the root id to the
  error panel the outer catch installs, in both hosts.
- Retargeting an id clears the previous control's reverse slot.
- CHANGELOG entry for SourceLocation.ColumnNumber, ComponentRendered and the
  ReactorTrace lookups (issue #1326).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…lumn-and-component-rendered

# Conflicts:
#	CHANGELOG.md
- Mount paths map a component's id to its wrapper only after the subtree has
  mounted; a descendant that throws into an enclosing ErrorBoundary discards
  the wrapper without unmounting it.
- Reconciler.ForgetComponentDiagnostics drops every tracked component's id;
  Reconciler.Dispose and both hosts' ShowErrorFallback (which replace the
  tree without unmounting it) call it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A function component has no memo gate, so it reported parent even when a
context value it consumes is what changed. Classify that case as context,
computed only while ComponentRendered is traced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When a hot-reload edit changes the root's hook order, the host resets the
root and retries outside the hot-reload pass, so ComponentRendered reported
that retry (root and children) as forced. The host now marks the pending
force pass as a hot-reload retry on the reconciler (diagnostics only; render
behaviour is unchanged) and both the root and child classification honour it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Trace a render that threw into an ErrorBoundary for every exception the
  boundary can catch (drop the OOM/SO exclusion on the trace-and-rethrow
  clauses).
- A propless component whose consumed context changed reports context, not
  parent; props components keep props-over-context precedence.
- Function-component updates read the event gate once.
- SourceLocation stays 16 bytes on 64-bit only; x86 grows from 8 to 12.
  The size test and spec 010 say so.
- source-mapping frontmatter and diagram alt text mention the column;
  architecture-overview and reconciliation recompiled for the moved snippet.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…iagram

DetachReactorState, the full-detach teardown, now drops the control's
ComponentRendered id in both directions like unmount does. The
source-mapping attribution diagram labels per-element attribution as
file:line:column and shows a column in the CallSite example.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The function-component context classification reads the event gate into
traceEnabled; the render path now reuses that snapshot instead of reading
the gate again, so a listener enabling between the two reads cannot turn a
context-driven render into one reported as parent.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- ReactorHost.Mount(Func) clears the component root, as ReactorHostControl
  does; the render loop checks the component root first, so the old root kept
  rendering under the new root's fresh mount id.
- The root's content mapping is updated on every content change: tracked while
  ComponentRendered is enabled, dropped while it is off, so it never names a
  control that left the screen.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…on root

Mount(Func) now retires the previous component root's RenderContext (its
effect cleanups run, as on Dispose) before clearing it, in both hosts;
otherwise a root UseEffect subscription or timer outlived the switch and was
no longer reachable by Dispose.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…mount

- Mounts inside an ErrorBoundary record the component ids they map; a boundary
  that catches (on mount or update) forgets every mapping made since it
  started, so a healthy sibling mounted before a throwing one does not stay
  mapped to a discarded wrapper.
- Both hosts retire whichever root is active (component or function context:
  cleanups run, both slots cleared, diagnostics reset) before either Mount
  overload installs the new one.
- Document that a host root reports state for host-requested re-renders too.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mount(component) with the instance already mounted now just requests a
render. Retiring it ran its effect cleanups and then reused the same
RenderContext, whose unchanged effects would never be scheduled again.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RetireRoot now resets the outgoing component's hook state
(RenderContext.ResetHookState: cleanups run and hook cells are dropped), so
mounting that caller-owned instance again later is a fresh mount: its effects
are scheduled again and old state does not survive. ResetForHotReload
delegates to the same method.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- A Render() aborted by a hot-reload hook-order change (child in the
  reconciler, root in either host) now emits ComponentRendered before the
  reset; the retry reports itself, timed on its own.
- A root whose Render() returns null is reported too (nothing to reconcile,
  but it rendered); a host with no root still reports nothing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…d exception

- The fallback catch emits ComponentRendered before building the error
  fallback, so the fallback's time is not counted and a fallback that throws
  cannot swallow the event.
- The trace-and-rethrow clause now covers every exception the fallback catch
  does not handle (an ErrorBoundary-bound one, or a fatal one propagating to
  the host), on mount and update.
- The update reason is computed once per render.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both hosts mark the root's hook-order retry on RootRenderDiagnostics, so the
root reports it as hotReload whether or not the host's hot-reload pass is a
forced one. ReactorHost (whose pass is forced and whose force flag is still
pending) keeps also marking the reconciler so its children follow;
ReactorHostControl's passes are not forced, so it no longer sets that
reconciler flag, whose comment overstated what it did there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…k names

Lets an out-of-process inspector (WinUI DevTools over XamlDiagnostics) show
Reactor's component tree, call sites, keys, declared names and hook names with
no managed agent in the app, so Native AOT apps can be inspected too.

K: ReactorDiagnostics.SourceProperty (attached string DP, "ReactorSource")
- Set on EVERY realized control in diagnostics mode (mount, update, skip-arm
  call-site refresh, hot-reload migration, component wrappers, host roots),
  not only the controls Reactor tags. Value v1:
  v=1|at=path:line[:col][|rel=root|0]|owner=C|element=Kind[|mounts=C][|root=C]
     [|key=k][|name=n][|hooks=i:name@line;...]   (% -> %25, | -> %7C)
- at= is never an absolute developer path (XAML ms-appx parity): relative to
  the project directory, else to the solution root (rel=root), else the file
  name only (rel=0); deterministic /_/ paths are kept. CallSite is unchanged.
- Gating: Reactor.DevtoolsSupport switch AND REACTOR_DIAGNOSTICS=1. Call sites
  guard on the new cached feature switch ReactorFeatures.DevtoolsSupported,
  so trimmed/AOT builds without it carry no publishing code (AOT Hello-World
  trim assertions extended; positive control with the switch on keeps it).
- $(ReactorDiagnostics) MSBuild property: default true in Debug; true turns
  the switch on (Trim=true) and implies ReactorSourceMap; explicit settings,
  and a project-declared switch over the Debug default, win.

I: declared names and hook names (source-map generator, Debug/source-map only)
- The generator records the identifier an element is assigned to (locals,
  fields, properties, assignments, expression-bodied members/local functions,
  through fluent chains) -> SourceLocation.DeclaredName and name=.
- Per class Render() / render function: ordered Use* calls with the variable
  they are stored in and their line -> hooks=; slot indices exact for
  single-slot built-ins (runtime-verified table) and straight-line custom hooks
  in the project, '?' after unknown or conditional hooks.
- Emitted as a module initializer that registers a fill callback; tables are
  built lazily on first lookup.

Tests: generator snapshots (names, hooks, roots), live source-map tests,
format/escaping/relative-path/gating unit tests, hook slot table guard,
build-targets evaluation tests, ReactorSource selftest fixture (not run).
Docs: diagnostics, source-mapping guide; threat model §7.7.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…can change

Measuring diagnostics mode (K) on a 1k-50k control benchmark found a
correctness bug and most of its cost in the publisher's per-control side table.

Bug: the last published owner/value lived in a ConditionalWeakTable keyed by
the managed UIElement. CsWinRT wrappers are collected and re-created for the
same native control across GCs, so entries vanished and a shallow-skip
re-publish (call site moved) wrote the value without |owner=. Measured: after
the benchmark's re-render series only 3,078 of 9,999 controls kept owner=
(1k: 306/990); with a forced GC before each re-render it showed after 2.

- No per-control state. Skip arms take the owner from the reconciler's current
  owner (they run inside the parent's reconcile, like updates).
- The update path re-publishes only when the value can change:
  IdentityChanged = call site, key, element kind or component type differ (or
  the control was replaced). An ordinary re-render builds and writes nothing.
- The host root keeps its last value on the reconciler (one per host).
- Values are cached per shape (site, owner, kind, mounts, root, name, hooks):
  key-free controls share one string; keyed ones are one concat. Format() is
  split out and pinned byte-for-byte to the v1 grammar.

Effect (Native AOT, 50k nested, delta vs no-K, per control): managed heap
559 -> 372 B; mount allocations 1,403 -> 610 B; allocation per unchanged
re-render 1,158 -> 300 B; re-render with changed call sites 27 -> 10 us.
Switch-off (b) and switch-on/env-unset (c) builds remain within noise of the
baseline; the trimmer still removes all of it when the switch is off (mstat).

Tests: ReactorSourcePublisherTests (identity rules; cached Format equals the
direct Build for plain/keyed/escaped/no-site/no-owner/column-0/absolute
shapes; shared instance for unkeyed values); selftest skip-republish owner.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Stamping (REACTOR_SOURCEMAP=1, and diagnostics mode, which implies it) gave
every element a fresh ElementExtras bucket (~150 B) holding only its
CallSite, retained for every realized control. The bucket is an immutable
record, so an element whose only extra is its call site now takes a shared
instance cached per SourceLocation (bounded by the app's call sites; the cache
lives in a holder class, so an app that never stamps never allocates it).
Adding any behavioral extra still copies the bucket.

Effect (Native AOT, 50k nested, per control, delta vs no stamping):
stamping-only heap 354 -> 202 B; diagnostics mode heap 372 -> 220 B and
allocation per unchanged re-render 300 -> 142 B.

Docs: diagnostics guide states the measured cost of diagnostics mode.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every guarded reconciler call site reads ReactorSourcePublisher.IsEnabled, which runs the type's static initializer even when REACTOR_DIAGNOSTICS is unset (the Debug default). The value cache (a ConcurrentDictionary) was a static field there, so a Debug app with the switch on and publishing off retained ~14 KB it never used (measured: flat 1k heap after mount +1.9% vs baseline, now +0.0%). It now lives in a holder class initialized on first publish.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
In diagnostics mode every stamped element was tagged (ReactorState + its
attached DP) only so a managed inspector could read its call site back. No
managed agent can load into a Native AOT process, so there the tag is pure
cost: measured ~0.2 KB managed and ~0.8 KB native per control.

- NeedsTag no longer tags an element whose only extra is its call site when
  Reconciler.SkipsCallSiteOnlyTags: the Reactor.DevtoolsSupport switch,
  REACTOR_DIAGNOSTICS=1, and no managed agent
  (!RuntimeFeature.IsDynamicCodeSupported, decided once; settable for tests).
  Such a control is in exactly the state an unstamped build leaves it, which
  every functional tag reader already handles. Forced tags (decorators,
  wrappers, dialogs, lists, Win2D, generated wrappers) and tags for
  callbacks / keys / behavioural extras / reference modifiers are untouched.
- The spec-010 shallow-skip refresh allocates only via NeedsTag
  (RefreshCallSiteTagOnSkip) instead of forcing a tag.
- ReactorSourceMap.GetSource resolves an untagged control from its published
  value. Each value shape records its full SourceLocation against its at=
  text, so it returns the same SourceLocation the tag gave. When two call
  sites share the same at= text (same relative path from two assemblies),
  that control is tagged after all, so GetSource stays exact.
- JIT, Debug, and builds without the switch are unchanged; the trimmer still
  removes everything.

Measured (Native AOT, 50k controls, diagnostics on, per control vs no K):
managed heap 221 -> 20 B; private bytes 1,512 -> 713 B; mount allocations
458 -> 199 B; mount time 9.8 -> 3.3 us. Switch off / switch on with
REACTOR_DIAGNOSTICS unset stay within noise of the baseline.

Tests: a selftest runs one tree through both paths and requires GetSource to
agree on every control after mount and after a re-render that moves call
sites (43 controls, 30 located; 11 tags instead of 30, 19 resolved from the
published value). Mutation-checked: no fallback, or no republish on skip,
fails it. Unit tests: at= key parsing, published value -> full location,
ambiguity, mode predicate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Security: only the SDK's PathMap roots (/_/, /_<digits>/) count as
  deterministic, non-absolute paths. Any other /_... directory (/_work/...)
  is absolute and is published as its file name with rel=0.
- ItemsRepeater adoption moves a fresh component subtree into the realized
  wrapper; the wrapper now also takes the replacement's published value, so
  it reports the new key.
- AOT GetSource fallback: a collision of at= texts no longer erases the first
  mapping. Controls published after a collision keep their tag; an untagged
  control with that text was published before it, for the first site.
- Tests: published name= and hooks= on live controls; adoption republish
  (mutation-checked); /_work paths; collision keeps the first site.
- Docs: CHANGELOG entry; reactor-build-and-check skill documents
  ReactorDiagnostics and the Debug default; unambiguous rel= in the
  SourceProperty XML doc; threat model states the PathMap-root rule.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Security: a path rooted on the current drive (\Users\me\App.cs) is
  absolute too; it is published as its file name with rel=0.
- Owner: the host's root owner is set only for its own render pass and
  cleared after it. Rows an ItemsRepeater realizes or reuses during layout
  run Reconcile outside any render, and used to pick up a stale root owner
  (owner= is now omitted for them, as documented).
- Tests: rooted-path cases; a scroll selftest with keyed component rows
  (pass-2 adoption during layout), mutation-checked (all 20 reused rows
  reported the stale owner without the fix).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… visit

A panel strategy's collection can belong to an inner panel the host's template
has not attached, outside the visual and logical walks, so root renames and
stale-fact invalidation missed its children. VisitLiveChildren now visits it.
Live regression with a custom untemplated host.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cross-cutting Native AOT tagging, teardown and trimming changes warrant final human review backed by Windows/AOT validation.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

…lumn-and-component-rendered

# Conflicts:
#	CHANGELOG.md
#1328 was squash-merged, so its commits (already merged here, last b96e59a)
are not ancestors of main. Conflicts were resolved by a three-way merge of each
file against b96e59a: the result is ours plus exactly main's changes since
#1328's final head (#1358 et al.). The auto-merge re-added the superseded
Reactor.DevtoolsSupport row to reactor-build-and-check/SKILL.md; kept ours.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native AOT tag suppression spans attribution, teardown and host lifecycles, warranting final human review of Windows runtime validation.

0 open findings

🧠 Review effort: Balanced

Comment thread src/Reactor/Core/Diagnostics/ReactorSourcePublisher.cs Fixed
Comment thread src/Reactor/Core/Navigation/NavigationCache.cs Fixed
Comment thread src/Reactor/Core/Reconciler.Update.cs Fixed
Comment thread src/Reactor/Diagnostics/ReactorSourceMap.StaticInfo.cs Fixed
Cherry-picked from 722b41e, #1328's post-merge doc fix (pushed after the
squash merge, so not on main).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native AOT tag skipping affects teardown-sensitive WinUI lifecycle paths and warrants final human review of runtime and trimming validation.

0 open findings

🧠 Review effort: Balanced

Combine the nested hot-reload check in WithoutStaleFacts, filter the three
diagnostics-only loops (navigation cache snapshot, inline UI paragraphs,
longest-prefix root) up front, and make the keyed-memo fixture's factory
counter a local.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cross-cutting Native AOT tagging, teardown and host lifecycle changes warrant final human review of Windows and AOT validation results.

0 open findings

🧠 Review effort: Balanced

Comment thread src/Reactor/Diagnostics/ReactorSourceMap.StaticInfo.cs Fixed
Follow-up code-quality notes: Where/Select instead of skip-and-continue in the
generator's fact, hook-slot and destructuring loops, and MaxBy for the longest
root prefix. No behaviour change (generator snapshot and root-relative tests).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cross-cutting Native AOT tagging, teardown, and build-gating changes require final human validation.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity Stamped decorator reports its call site for unstamped targets

src/​Reactor/​Core/​Reconciler.Mount.cs:149

A stamped decorator with an unstamped target publishes the decorator's location as the target's creator. For example, Flyout(new TextBlockElement("target"), TextBlock("body")) reaches ReactorSourcePublisher.Format, where effective.CallSite ?? element.CallSite falls back to the Flyout call. Inspectors then receive the wrong at= and potentially name=, while ReactorSourceMap.GetSource returns null for the target. Use only the unwrapped target's call site in both Format and its identity comparison helper Describe, and add mount/update coverage for an unstamped target.

🧠 Review effort: Balanced

Comment thread tests/Reactor.Tests/Tooling/ReactorDiagnosticsTargetsTests.cs Fixed
Comment thread tests/Reactor.SourceMap.Tests/RootMountInterceptionTests.cs Fixed
Comment thread tests/Reactor.SourceMap.Tests/StaticInfoTests.cs Fixed
A stamped decorator over an unstamped target published the decorator's
location as the target control's at= (and name=), while GetSource reports no
location for it. Format and IdentityChanged now take the call site from the
unwrapped target alone, exactly as GetSource does. Unit test plus a live
mount/update fixture. Also: code-quality notes (Select in the generator's
hook-slot walk, a documented best-effort catch, Path.Join in two tests).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native AOT tagging and teardown changes need final human validation, and the retained test-gating issue remains unresolved.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity Avoid using tested output to gate source-map publication checks

tests/​Reactor.AppTests.Host/​SelfTest/​Fixtures/​ReactorSourceNullRenderFactsFixture.cs:47

The support check uses the output under test: if a source-mapped host stops publishing at=, this returns before any retained-content assertions. The HostControl caller also ignores the returned false, so that entire path can be bypassed while the Host assertions leave the fixture passing. The same gate appears in ReactorSourceNullRenderLateConflictFixture.cs:40 and ReactorSourceStaleFactsFixture.cs:69 (where it reports a skip).

Determine stamping support independently, for example with a factory-created element's CallSite after source mapping is enabled, as ReactorSourceFixture.cs:900–905 does. Once support is established, use H.Check to require a non-null published value containing at= on each host path. If it is missing, clean up and return only after recording that failure. Apply this to all three fixtures so publication failures cannot masquerade as unsupported builds.

🧠 Review effort: Balanced

The null-render, late-conflict and stale-facts fixtures decided whether the
host stamps call sites from the published value under test, so a host that
stopped publishing at= skipped (and the HostControl path ignored the result)
instead of failing. They now probe a factory call's CallSite with source
mapping on, and once stamping is established require at= with H.Check on every
host path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native AOT tag removal affects teardown-sensitive paths alongside MSBuild integration, warranting final human review despite no confirmed blocking defect.

0 open findings

🧠 Review effort: Balanced

Hosts: kept both sides. Main's DiagnosticsRootResolver alongside K's
diagnostics-implies-source-map setup; main's passRoot snapshot alongside K's
_renderingMountSite/_renderingFunctionRoot; on a null render, K's
RepublishRetainedRoot runs before main's _renderedRoot bookkeeping (independent).
API index regenerated (UPDATE_API_INDEX=1). K never relied on the secret
redaction #1332 removed: ReactorSource publishes static facts only.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Unresolved collectible-assembly retention and cross-cutting Native AOT lifecycle changes require fixes and final human validation.

2 open issues

Review effort: Balanced

Comment thread src/Reactor/Core/Diagnostics/ReactorSourcePublisher.cs
Comment thread src/Reactor/Diagnostics/ReactorSourceMap.StaticInfo.cs Outdated
ReactorSource diagnostics kept a collectible AssemblyLoadContext alive when the
plug-in shares the host's Reactor:
- the pending static-info registrations (assembly + fill callback) and the
  built table's assembly set and component-hook keys held the assembly
  strongly. Both are now owned weakly by the assembly (ConditionalWeakTable);
  location-keyed tables hold strings only;
- the published-value cache keyed shapes by element Type; it now keys by the
  formatted kind string, cached per type in a ConditionalWeakTable;
- ControlRegistry's source-target and base-dispatch caches, which every
  published control goes through, no longer cache collectible types.

Unload regression tests (before and after the first table lookup, and after
formatting a plug-in element), each mutation-checked.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread tests/Reactor.Tests/Diagnostics/ReactorSourceUnloadTests.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Dialog cleanup remains unresolved, and Native AOT teardown and attribution changes require final human validation.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
The dialog's close cleanup cleared only the element pointer, so a dialog an app
keeps through .Set(...) still carried its ReactorSource, which Native AOT's
GetSource falls back to (JIT already returned null). It now uses
ClearElementTag, which also clears the published value. Live check in both tag
modes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native AOT tag removal affects source resolution and teardown across multiple lifecycle paths, warranting final human review of Windows validation results.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Inspector diagnostics without a managed agent (Native AOT): publish ReactorSource on every control, declared names and hook names

2 participants