You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Inspector diagnostics without a managed agent: ReactorSource on every control, declared names, hook names (Native AOT) - #1342
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.
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:
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:
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>
- 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>
…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>
… 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>
#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>
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>
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>
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>
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
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.
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>
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.
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>
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.
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>
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>
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
What's new
ReactorDiagnostics.SourceProperty("ReactorSource"in XamlDiagnostics): published on every realized control in diagnostics mode, including component wrappers and host roots.%becomes%25and|becomes%7C. Readers ignore unknown fields.owner/mounts/rootuse the same component names asRenderError/ComponentRendered.hooksindices areRenderContextslots, or?once a slot can't be known statically.SourceLocation.DeclaredName: the identifier an element was assigned to, likex:Name(var title = TextBlock(…).Bold()givestitle). 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.Reactor.DevtoolsSupportswitch, controlled by a new MSBuild propertyReactorDiagnostics. It istruein Debug by default and impliesReactorSourceMap=true. Use-p:ReactorDiagnostics=truefor a Release / Native AOT devtools build. An explicit value always wins, and a project-declaredReactor.DevtoolsSupportitem wins over the Debug default.REACTOR_DIAGNOSTICS=1.ReactorFeatures.DevtoolsSupported && …checks, so ILC removes all of it from a build without the switch.ms-appx:///….at=is relative to the project directory, else to the solution root (rel=root), else the file name only (rel=0)./_/,/_<n>/) are kept as they are.Element.CallSiteis unchanged.ReactorStateplus its DP) only so a managed inspector could read its call site.!RuntimeFeature.IsDynamicCodeSupported, cached once), soNeedsTagnow skips an element whose only extra is its call site.ReactorSourceMap.GetSourcereturns the sameSourceLocationfrom the published value: each cached value shape records its full location against itsat=text. If two call sites share oneat=text, controls published after the collision keep their tag.RegisterTyperegistration keeps describing its child: the published value stays the child's, andGetSourcereads it when the registration had to tag the control with itself.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:SetElementTag:OnOpened/OnClosedread the tag;AnimationHelper;Reconciler.GetElementTagreturnsnullfor 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
ConditionalWeakTablekeyed by theUIElement). CsWinRT wrappers are collected and re-created for the same native control, so entries vanished after GCs and re-published values droppedowner=. Measured: only 3,078 of 9,999 controls kept it. Now there is no per-control state:Two other changes:
ElementExtrasbucket per call site, instead of a ~150 B bucket per element.Linked issue / spec
Fixes #1341
Spec 010 (source attribution). Builds on #1328.
Integration with #1318 and #1323 (both on
main). Included in this PR:hooks=resolves through Diagnostics: enumerate Reactor hosts and their roots; tag component boundaries for inspectors #1318's root mount site (the source map keys a render function's hooks by the call it was passed to, which is exactly that site);owner/mounts/rootnames come from Name the component, not its element, in RenderError — and report mount, host-root and ErrorBoundary-caught render errors #1323'sComponentNames.For; K no longer formats component names itself.Covered by
RootRenderFunctionHooks_ResolveFromTheMountSite(SourceMap.Tests) and theReactorSource_RootRenderFunctionHooksselftest 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/_workvs/_/and/_1/, gates, feature-switch attribute.ReactorSourcePublisherTests:Formatbyte-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 → fullSourceLocation; collision keeps the first site;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 realReactor.targetsin both import orders.ElementExtrasAllocationTests.StampedLeavesFromOneCallSiteShareTheBucket.dotnet test tests/Reactor.SourceMap.Tests -p:Platform=x64: 96 / 96, includingStaticInfoTests.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 requiresGetSourceto 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_AdoptedRowRepublishedandReactorSource_LayoutRealizedRowsHaveNoOwner: ItemsRepeater adoption, and rows realized or reused while scrolling.dotnet publish tests/aot_trim_proof/Reactor.AotHelloWorld -c Release -r win-x64 -p:PublishAotInternal=true -p:Platform=x64, thenReactor.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 fullReactor.slnxRelease build locally (NuGet was unreachable, so restore failed); it showed no compile warnings or errors.diagnosticsandsource-mappingguides recompiled.pr-reviewskill: 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:/_workpath and a\-rooted path were published unrelativized;at=texts lost the first location;name=/hooks=had no live coverage;rel=XML doc needed updates.Performance: Native AOT and JIT Debug, 1k / 10k / 50k controls, at least 5 interleaved runs per cell
Method.
LazyVStacklist.REACTOR_DIAGNOSTICSunset (the new Debug default).~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: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):
What remains:
with { CallSite }copy, whichREACTOR_SOURCEMAP=1has 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
ReactorDiagnosticsturnsReactor.DevtoolsSupporton in Debug for every Reactor app; the scaffolded template already did. Devtools surfaces still need their launch verb, and nothing is published withoutREACTOR_DIAGNOSTICS=1. Opt out with-p:ReactorDiagnostics=false. Measured within noise when not launched for inspection.ReactorDiagnostics.SourceProperty,ReactorDiagnostics.GetSourceandReactorDiagnostics.IsSourcePublishingEnabled;SourceLocation.DeclaredName;ReactorSourceMap.RegisterStaticInfoandReactorStaticInfoBuilder([EditorBrowsable(Never)]).ElementExtrasinstance. Adding any behavioural extra still copies the bucket.Reconciler.GetElementTagisnullfor call-site-only controls, as for any untagged display leaf.GetSourceis unchanged.