Skip to content

[XAML] Incremental XAML Hot Reload (source-generated patch chains)#34338

Open
StephaneDelcroix wants to merge 7 commits into
net11.0from
feature/xaml-incremental-hotreload
Open

[XAML] Incremental XAML Hot Reload (source-generated patch chains)#34338
StephaneDelcroix wants to merge 7 commits into
net11.0from
feature/xaml-incremental-hotreload

Conversation

@StephaneDelcroix

@StephaneDelcroix StephaneDelcroix commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description

This PR adds XAML Incremental Hot Reload (XIHR) — a Roslyn source generator + runtime feature that lets live XAML edits update existing instances without rebuilding the app. When the developer edits a XAML file, the generator emits a per-version patch method (UpdateComponent) and the [MetadataUpdateHandler] runtime applies it to every live instance of the affected page, advancing each instance through its accumulated patch chain.

The feature is opt-in via project property (<EnableIncrementalHotReload>) and gated by a RuntimeFeature switch (IsIncrementalHotReloadEnabled) so trimmed/AOT production builds pay zero cost.

What changed

  • Source generator (src/Controls/src/SourceGen/):

    • XamlGenerator orchestrates per-file IC + UC emission with versioning.
    • InitializeComponentCodeWriter emits the initial __version field and Register(...) calls.
    • UpdateComponentCodeWriter emits per-version patch bodies (if (__version == N) { …; __version = N+1; }), supporting property changes, child add/remove, structural reorders, attached properties, markup extensions, bindings, and ResourceDictionary updates.
    • XamlNodeDiff computes the semantic diff used to decide patch vs. structural reset vs. empty (no-op) diff.
    • XamlHotReloadState keeps the per-file (prev XAML, parsed tree, node IDs, version, patch bodies) cache across generator invocations.
  • Runtime (src/Controls/src/Xaml/):

    • XamlComponentRegistry tracks live instances + named components per page via ConditionalWeakTable and weak references.
    • XamlIncrementalHotReloadHandler ([assembly: MetadataUpdateHandler]) snapshots the registry on a metadata update, then dispatches UpdateComponent() calls on the UI thread.
  • Feature switch: RuntimeFeature.IsIncrementalHotReloadEnabled with [FeatureSwitchDefinition] + [FeatureGuard] so the trimmer can dead-strip the runtime when disabled.

  • Sample: Maui.Controls.Sample.Sandbox demonstrates the developer scenario.

Tests

  • 414 SourceGen unit tests covering: foundation, IC/UC emission, all patch shapes, structural diff, ResourceDictionary handling, registry round-trips, full E2E pipeline (multi-run scenarios).
  • 26 runtime tests for XamlComponentRegistry (registration lifecycle, weak-ref cleanup, prefix rename).
  • Notable regression test: NoOpEdit_BetweenPatches_PreservesVersionChain — semantic no-op edits (e.g., adding a comment) must NOT reset version or clear accumulated patches.

Review history

This branch went through 5 rounds of multi-model code review (Claude Sonnet 4.6, Claude Opus 4.7, GPT-5.5 in parallel). 7 round-1 blockers + 13 majors + 3 round-2 findings + 7 round-3 findings + 4 round-4 findings were all addressed. Round-5 verdict: LGTM from all 3 reviewers, high confidence.

Known follow-ups (not blockers for this PR)

  • True IncrementalGenerator purity: The current implementation uses static mutable state (XamlHotReloadState) keyed by (assembly, TFM, relativePath). A future refactor to pure IncrementalValueProvider pipelines would reduce coupling and improve build-server cacheability.
  • Long-lived IDE session cache pruning: XamlHotReloadState accumulates one entry per XAML file ever seen by the generator host. For multi-hour IDE sessions with file renames or deletes, entries are never reclaimed until generator-host shutdown. Adding pruning from the current AdditionalTexts snapshot is a small follow-up.

Targeting

Base branch: net11.0 (this is a new feature, not a bug fix).

Copilot AI review requested due to automatic review settings March 4, 2026 20:01
@github-actions

github-actions Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34338

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34338"

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.

Pull request overview

This PR adds incremental XAML Hot Reload support to the XAML Source Generator pipeline by diffing XAML trees across generator runs and emitting versioned UpdateComponent_vNtoM() methods plus a per-page [MetadataUpdateHandler] dispatcher. Runtime support is introduced via a component registry to map live instances to stable node IDs.

Changes:

  • Introduces a XAML tree diff engine + stable node-id assignment and uses them to generate UpdateComponent_vNtoM() source on property-only edits.
  • Adds runtime XamlComponentRegistry (with PublicAPI entries) used by generated code and the metadata update handler.
  • Adds/updates unit and integration tests and extends the source-gen test driver to plumb the MSBuild opt-in flag.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Controls/src/SourceGen/XamlGenerator.cs Wires incremental HR into generator output; emits UC + handler sources; updates in-proc state cache.
src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs Emits __version field + registry registration; adds helpers for root type resolution and UC generation.
src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs New UC code generator producing UpdateComponent_vNtoM() methods from diffs.
src/Controls/src/SourceGen/MetadataUpdateHandlerCodeWriter.cs New generator emitting [MetadataUpdateHandler] glue to apply updates to live instances.
src/Controls/src/SourceGen/XamlNodeDiff.cs New property-only diff engine for parsed XAML trees (structural changes => null).
src/Controls/src/SourceGen/NodeIdHelper.cs New helper assigning stable {Type}_{depth}_{index} node IDs.
src/Controls/src/SourceGen/XamlHotReloadState.cs New in-process cache for previous XAML + version per (assembly, relative path).
src/Controls/src/Xaml/XamlComponentRegistry.cs New runtime registry (weakly keyed by instance) for nodeId → component and instance enumeration.
src/Controls/src/Xaml/PublicAPI/*/PublicAPI.Unshipped.txt Adds new public API entries for XamlComponentRegistry across TFMs.
src/Controls/tests/SourceGen.UnitTests/SourceGeneratorDriver.cs Extends test driver options to include EnableMauiIncrementalHotReload.
src/Controls/tests/SourceGen.UnitTests/InitializeComponent/SourceGenXamlInitializeComponentTests.cs Updates hint-name filtering and adds IHR opt-in parameter plumbing.
src/Controls/tests/SourceGen.UnitTests/InitializeComponent/IncrementalHotReloadICTests.cs New tests validating IC emits __version + registry registrations when IHR is enabled.
src/Controls/tests/SourceGen.UnitTests/XamlNodeDiffTests.cs New unit tests for diff behavior (property diffs vs structural null).
src/Controls/tests/SourceGen.UnitTests/NodeIdHelperTests.cs New tests verifying stable node ID assignment.
src/Controls/tests/SourceGen.UnitTests/UpdateComponentCodeWriterTests.cs New tests for UC generation output shape and content.
src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadPipelineTests.cs New “two-run” integration tests covering generator replay behavior and UC/handler emission.
src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj Enables MetadataUpdaterSupport for tests involving hot reload infra.
src/Controls/tests/Core.UnitTests/XamlComponentRegistryTests.cs New unit tests validating registry behavior (register/tryget/unregister/getinstances).

Comment on lines +112 to +118
// Extracts generated sources from a run result by hint-name suffix
static ImmutableArray<GeneratedSourceResult> GetSources(GeneratorDriverRunResult result)
=> result.GeneratedTrees
.Select(t => t)
.Select(_ => default(GeneratedSourceResult))
.ToImmutableArray();

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

GetSources currently returns an array of default GeneratedSourceResult values derived from GeneratedTrees, but it doesn’t actually extract any generated sources and appears unused. This looks like leftover scaffolding; consider removing it to reduce confusion, or implement it properly and use it in assertions.

Suggested change
// Extracts generated sources from a run result by hint-name suffix
static ImmutableArray<GeneratedSourceResult> GetSources(GeneratorDriverRunResult result)
=> result.GeneratedTrees
.Select(t => t)
.Select(_ => default(GeneratedSourceResult))
.ToImmutableArray();

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Stale comment — GetSources is no longer present in this file (the test helper was reworked into ThreeRuns in a later commit). No action needed.

Comment on lines +123 to +126
{
// Type unknown — use dynamic dispatch (slower but safe)
castPrefix = $"({varName} as global::Microsoft.Maui.Controls.BindableObject)?.";
}

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The fallback branch for an unresolved node type emits ({varName} as BindableObject)?.{Property} = ..., which will not compile: (1) conditional access (?.) is not allowed on the left-hand side of an assignment, and (2) most properties (e.g. Text) don’t exist on BindableObject at compile time. Instead, if the node type can’t be resolved, the UC generator should conservatively fall back (e.g., emit goto fallback; for that node / return null to skip UC generation), or use a truly dynamic/reflection-based assignment that still compiles.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — this was a real bug. The fallback emitted (x as BindableObject)?.Prop = value which is invalid C# (conditional access on the LHS of an assignment). Fixed in b260645: when NodeXmlType can't be resolved, skip emission and write a // skipped: node XAML type could not be resolved. breadcrumb instead of attempting an unsafe cast. The runtime fallback via BindableObject was also unreliable because most properties (e.g. Text) aren't defined there.

Comment on lines +262 to +272
if (fqName is "int" or "global::System.Int32" or "long" or "global::System.Int64"
or "short" or "global::System.Int16" or "byte" or "global::System.Byte")
return rawXamlValue; // emit as-is (numeric literal)

// Float/double
if (fqName is "float" or "global::System.Single")
return $"{rawXamlValue}f";
if (fqName is "double" or "global::System.Double")
return rawXamlValue;
if (fqName is "decimal" or "global::System.Decimal")
return $"{rawXamlValue}m";

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

Numeric property literals are emitted directly from the raw XAML string (e.g., for int/double/decimal). If the XAML value isn’t a valid numeric literal (or uses a format that doesn’t compile), this will produce uncompilable generated code rather than falling back. Consider validating with invariant-culture TryParse for each numeric kind and returning null (goto fallback) when parsing fails.

Suggested change
if (fqName is "int" or "global::System.Int32" or "long" or "global::System.Int64"
or "short" or "global::System.Int16" or "byte" or "global::System.Byte")
return rawXamlValue; // emit as-is (numeric literal)
// Float/double
if (fqName is "float" or "global::System.Single")
return $"{rawXamlValue}f";
if (fqName is "double" or "global::System.Double")
return rawXamlValue;
if (fqName is "decimal" or "global::System.Decimal")
return $"{rawXamlValue}m";
if (fqName is "int" or "global::System.Int32"
or "long" or "global::System.Int64"
or "short" or "global::System.Int16"
or "byte" or "global::System.Byte")
{
if (fqName is "int" or "global::System.Int32")
{
if (int.TryParse(rawXamlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return rawXamlValue; // emit as-is (numeric literal)
return null;
}
if (fqName is "long" or "global::System.Int64")
{
if (long.TryParse(rawXamlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return rawXamlValue;
return null;
}
if (fqName is "short" or "global::System.Int16")
{
if (short.TryParse(rawXamlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return rawXamlValue;
return null;
}
if (fqName is "byte" or "global::System.Byte")
{
if (byte.TryParse(rawXamlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return rawXamlValue;
return null;
}
}
// Float/double
if (fqName is "float" or "global::System.Single")
{
if (float.TryParse(rawXamlValue, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _))
return $"{rawXamlValue}f";
return null;
}
if (fqName is "double" or "global::System.Double")
{
if (double.TryParse(rawXamlValue, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _))
return rawXamlValue;
return null;
}
if (fqName is "decimal" or "global::System.Decimal")
{
if (decimal.TryParse(rawXamlValue, NumberStyles.Number, CultureInfo.InvariantCulture, out _))
return $"{rawXamlValue}m";
return null;
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not actually the case — numeric values aren't emitted from the raw XAML string. They flow through NodeSGExtensions.ValueForLanguagePrimitive (Int16/Int32/Int64/UInt16/UInt32/UInt64/Single/Double/Decimal/Byte/SByte branches in NodeSGExtensions.cs:402-540), each using TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, …) with a ConversionFailed diagnostic on failure, and output via SymbolDisplay.FormatPrimitive which always produces a valid C# literal. No action needed.

Comment on lines +155 to +169
// Emit Register calls and __version bump for incremental hot reload
if (nodeIds != null)
{
codeWriter.WriteLine();
foreach (var kvp in sgcontext.Variables)
{
if (kvp.Key is ElementNode en
&& nodeIds.TryGetValue(en, out var nodeId)
&& !string.IsNullOrEmpty(nodeId))
{
codeWriter.WriteLine($"global::Microsoft.Maui.Controls.Xaml.XamlComponentRegistry.Register(this, \"{nodeId}\", {kvp.Value.ValueAccessor});");
}
}
codeWriter.WriteLine("__version = 1;");
}

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

When EnableDiagnostics hot-reload fallback triggers (InitializeComponentRuntime(); return;), the new incremental-hot-reload bookkeeping (XamlComponentRegistry.Register(...) + __version = 1;) is skipped entirely. That leaves instances unregistered and/or with stale __version, so subsequent incremental updates will consistently fail/fallback. Consider explicitly opting the instance out before returning (e.g., unregister + reset __version), or ensure the runtime-inflation path also sets up registry/version so IHR can resume after a runtime reload.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good observation. The legacy ResourceProvider2 fallback (Xamarin-era pure-XAML hot reload) is a coexistence path: when it returns content, InitializeComponentRuntime re-parses the XAML and creates fresh children that the static IC code knows nothing about. We emit Unregister(this) + __version = 0 so the page's stale XIHR registrations are dropped, but we deliberately don't re-Register the new children (the legacy path doesn't know our node IDs). This makes the page a silent no-op for XIHR until the next normal Initialize — which is the right outcome: incremental hot reload is opt-in and meant to replace, not augment, the legacy ResourceProvider2 path. Happy to add an explicit code comment documenting this if you think it's worth it.

readonly struct NodeDiff(string nodeId, IReadOnlyList<PropertyDiff> propertyChanges, XmlType? nodeXmlType = null)
{
/// <summary>
/// Stable path from the root, e.g. <c>""</c> for root, <c>"Label[0]"</c>, <c>"VerticalStackLayout[0]/Label[0]"</c>.

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The NodeId XML-doc/comment describes an index-path format like Label[0] / VerticalStackLayout[0]/Label[0], but the implementation generates IDs in the {TypeName}_{depth}_{index} format (e.g., Label_1_0). Please update the comment to match the actual ID format to avoid misleading future maintainers.

Suggested change
/// Stable path from the root, e.g. <c>""</c> for root, <c>"Label[0]"</c>, <c>"VerticalStackLayout[0]/Label[0]"</c>.
/// Stable path from the root, composed of segments in the <c>{TypeName}_{depth}_{index}</c> format
/// (e.g. <c>""</c> for root, <c>"Label_1_0"</c>, <c>"VerticalStackLayout_0_0/Label_1_0"</c>).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Stale comment — NodeId doc-comment now accurately describes the actual format ("" for root, "0", "1" etc., assigned depth-first by NodeIdHelper). See XamlNodeDiff.cs:58-61. The {TypeName}_{depth}_{index} format mentioned was from an earlier prototype.

@StephaneDelcroix StephaneDelcroix marked this pull request as draft March 4, 2026 21:20
@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch from 8f1a3cc to 459e08c Compare March 11, 2026 08:11
@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch 2 times, most recently from 1e2557d to 9c34d31 Compare March 28, 2026 07:45
@kubaflo

kubaflo commented May 24, 2026

Copy link
Copy Markdown
Contributor

/review -b feature/refactor-copilot-yml

@kubaflo

kubaflo commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

AI code review for net11.0 target

Verdict: Needs discussion (draft/WIP; non-approval automated review, no human approval implied)

Large feature PR (XAML Incremental Hot Reload via the source generator) by the XAML/SourceGen area owner. ~45 files including a new diff/codegen pipeline, runtime XamlComponentRegistry, and feature-switch plumbing. High-level pass, not an exhaustive audit.

Observations:

  • Properly feature-gated. RuntimeFeature.IsIncrementalHotReloadEnabled defaults to false, is wired via [FeatureSwitchDefinition] (NET10+) and the EnableMauiIncrementalHotReload MSBuild prop, and the handler early-returns when disabled. Good trimming/AOT hygiene and safe opt-in.
  • New public XamlComponentRegistry API (Register/TryGet/Unregister/GetInstances/ReRoot) is reasonably public since generated InitializeComponent()/UpdateComponent_vNtoM() in user assemblies must call it. Args are null-checked.
  • Registry is documented as keyed on the page instance and holding components — please confirm the lifetime story (weak references / Unregister on teardown) so live-instance tracking can't leak pages across hot-reload generations. The XML docs hint at weak holding; worth verifying in code review.
  • Structural-change → full-reload fallback (vs property-only diff) is a sensible safety valve. Test coverage is substantial (diff, pipeline, E2E, node-id helpers).

CI: required pipelines red, expected for a WIP draft; not assessed as merge-ready.

Confidence: medium and intentionally high-level — main pre-merge items are the registry lifetime/leak question and final API review.

@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch from bc39b90 to 4e24d57 Compare June 10, 2026 12:03
StephaneDelcroix and others added 6 commits June 10, 2026 15:10
Add the foundational components for XAML Incremental Hot Reload (XIHR):

- XamlNodeDiff: source-generator-side diff engine that compares old vs.
  new SGRootNode trees and produces a descriptive diff (property
  changes, child add/remove, structural changes) without any codegen
  concerns.

- XamlComponentRegistry: runtime registry mapping component types to
  live instances and node IDs, enabling the MetadataUpdateHandler to
  locate the right instances when XAML changes.

- NodeIdHelper: assigns stable IDs to XAML nodes.

- InitializeComponent codegen: emits __version field and registers the
  component instance with XamlComponentRegistry.

- UpdateComponentCodeWriter: generates an UpdateComponent(diff, version)
  partial method that applies a diff to a live instance.

- Pipeline wiring (XamlGenerator/XamlHotReloadState), MetadataUpdateHandler
  glue codegen, and the static XamlIncrementalHotReloadHandler SDK class.

- Comprehensive unit and pipeline tests covering diff/IC/UC.

Documents the trim/AOT limitation on the generated handler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend XamlNodeDiff and UpdateComponent codegen to handle structural
changes in the XAML tree:

- Same-parent child reordering.
- Child add and remove operations.
- Same-type sibling matching for stable identity across reorders.
- Refactor node IDs from position-based paths to stable unique integers
  so renames/reorders don't invalidate identity.
- Single UpdateComponent() per component (per spec), replacing the
  earlier per-node versioning scheme.
- Cache parsed SGRootNode to avoid re-parsing old XAML against new
  compilation each generation.
- Make the diff engine purely descriptive (no codegen-driven fallback).
- Fix DiffProperties for Value ↔ Binding ↔ StaticResource ↔
  DynamicResource transitions.
- Add ToDebugString diff verification and property transition matrix
  tests.

Includes multiple rounds of review fixes (UnregisterSubtree, Layout
checks, prefix collisions, UC fallback alignment, ID test type safety).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a sandbox app that exercises the XAML Incremental Hot Reload
pipeline end-to-end. Adds the EnableMauiIncrementalHotReload
CompilerVisibleProperty so the source generator can opt-in based on
project metadata. Fixes a duplicate GeneratedCodeAttribute on the
generated UpdateComponent partial class.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add full support for markup-driven properties in UpdateComponent:

- MarkupNode handling: {Binding}, {StaticResource}, {DynamicResource},
  C# expressions.
- {Binding Path, Mode=…, StringFormat=…} parameter support.
- Reuse IC's ExpandMarkupsVisitor and compile-time type converters in
  UC rather than maintaining a parallel parser/converter path.
- Reuse IC's markup-extension pipeline in UC, removing hand-coded
  expression handlers.
- Compiled TypedBinding emission in UC when x:DataType is known.
- Incremental handling of x:DataType changes (no structural fallback).
- Root-level child changes and content-container child handling.
- Attached bindable property support (set/clear via attached owner type).
- Handle MarkupNode properties on newly emitted elements.
- Smart same-type sibling matching reused across markup paths.

Includes structural child-change test coverage (remove, reorder, nested,
replace, multi-add) and attached-bindable-property tests; fixes IC
pipeline namespace handling for attached properties.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire the MetadataUpdateHandler into the MAUI SDK:

- Enable MetadataUpdateHandler registration in the SDK targets.
- Gate XIHR behind RuntimeFeature.IsIncrementalHotReloadEnabled so the
  runtime path is fully opt-in.
- Marshal UpdateComponent invocations to the main thread.
- Invalidate measure after UpdateComponent so visual refresh occurs.
- Rewrite the handler to track live instances via WeakReference,
  avoiding leaks of XAML components after hot reload.
- Move the MUH into the SDK and emit Track() calls from IC so each
  component registers itself with the runtime tracker.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Support hot reload of ResourceDictionary entries in UpdateComponent:

- Diff and codegen for resource add / remove / change.
- Replace blanket Clear() with targeted resource key tracking so only
  the resources that actually changed are updated.
- Track only emittable keys in UC (skip converters/custom types in the
  remove path; emit fresh instances for custom types on add).
- IC registers initial resource keys so removals are correctly diffed.
- Fix converter swap crash by emitting a runtime StaticResource lookup
  in UC for converter references.
- Fix converter resource removal: register custom types in IC.
- Fixes for CS0414 (__version), CS1061 (resource Contains), and CS0162
  (unreachable code) in generated resource UC patch bodies.
- E2E tests for resource hot reload scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch from 09607a6 to f276fc7 Compare June 10, 2026 14:25
@kubaflo

kubaflo commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

AI code review refresh for net11.0 target

Head reviewed: f276fc7c0e2cc58522032f406acc66822134c859 (the fleet scan's cbaee97… is stale; trusting actual PR head)
Target branch: net11.0 · State: open, draft/WIP
Verdict: Needs changes — the design feedback from the prior round is well addressed, but this PR's own new tests fail on Windows CI for two concrete, fixable reasons.

Prior-review reconciliation (round-15 → now)

The new commit f276fc7 explicitly closes my earlier open items:

  • Registry lifetime/leakXamlComponentRegistry now stores component maps and resource keys in ConditionalWeakTable, and the GetInstances secondary index uses WeakReference<object> with dead-entry pruning on each register. Pages GC freely; no strong-rooting. Concern resolved.
  • Double-bookkeeping (M10) — the handler's parallel weak list was dropped; UpdateApplication now queries GetInstances(type) directly. Track() retained as a no-op for already-compiled IC bodies (good backward-compat instinct).
  • Public API review (B2/M3) — all 7 PublicAPI.Unshipped.txt files aligned; XamlComponentRegistry marked [EditorBrowsable(Never)].
  • Determinism/AOT (M1/M2/M4)[FeatureGuard] on the runtime switch, \n + sorted HashSet emission for bit-deterministic codegen, SymbolDisplay.FormatLiteral for resource-key injection safety. The B1–B5/M1–M13 changelog is thorough and credible.

CI status (build 1457900)

Mixed. Classification:

  • 🔴 Windows Helix Microsoft.Maui.Controls.SourceGen.UnitTests (Debug+Release)PR-relevant, actionable. 17 failures, two root causes:
    1. 11× XamlNodeDiffTests.ToDebugString_* — CRLF≠LF assertion mismatches (Expected "…\n…", Actual "…\r\n…"). The codegen was made to emit \n, but ToDebugString / these fixtures still surface Environment.NewLine on Windows. Normalize line endings in ToDebugString (or the assertions) so the suite is host-OS-independent — this is exactly why a macOS-only local run reports green.
    2. XamlIncrementalHotReloadE2ETests.*MetadataUpdater.IsSupported is false … set <MetadataUpdaterSupport>true> / DOTNET_MODIFIABLE_ASSEMBLIES=debug. These E2E tests require runtime metadata-update support that isn't enabled on the Helix runner (and is off in Release). Guard them with a skip when MetadataUpdater.IsSupported == false, or ensure the test project sets the required property/env in all CI configs.
  • macOS Release build (error CA1416 in src/Graphics/.../iOS/UIImageExtensions.cs)unrelated to this PR (file not touched); pre-existing/base-branch analyzer breakage.
  • 🟡 AOT macOS/windows + RunOniOS TrimFull — red, but reported as harness-level (failedTests:0, "Test suite had 1 failure(s)"); not clearly attributable here. Given trim-safety is a stated design goal, please confirm these are not trimming regressions from the gated paths before un-drafting.

Blast radius

  • Runtime: opt-in only — RuntimeFeature.IsIncrementalHotReloadEnabled defaults false, [FeatureGuard]/[FeatureSwitchDefinition] gated, handler early-returns when off, registry [EditorBrowsable(Never)]. Negligible risk to users who don't enable XIHR.
  • Build/codegen: the generated InitializeComponent() body changes run for every XAML compile, so codegen determinism/correctness matters broadly — the \n/sorted-emission work is the right mitigation; the failing Windows tests are the guardrail catching residual host-dependence.

Findings summary

  • Must-fix before merge: the two Windows SourceGen test causes above (line-ending normalization; MetadataUpdater.IsSupported skip-guard).
  • Recommend: confirm AOT/TrimFull reds are not feature-related; keep the registry/Track() no-op compatibility note in the changelog.

Confidence: medium-high on the test-failure diagnosis (read from actual Helix results) and on the registry resolution (read at head); medium on the AOT/trim reds (not deep-dived).

Automated non-approval review. No human approval is implied or given; this does not gate merge.

@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch 3 times, most recently from 2d9c193 to 7f0c8ee Compare June 11, 2026 06:58
@StephaneDelcroix StephaneDelcroix changed the title [Feature] XAML Incremental Hot Reload via Source Generator [XAML] Incremental XAML Hot Reload (source-generated patch chains) Jun 11, 2026
@StephaneDelcroix StephaneDelcroix marked this pull request as ready for review June 11, 2026 09:40
@kubaflo

kubaflo commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

AI code review refresh for net11.0 target

Head reviewed: 7f0c8eee3dad3f281b19c05ef05825530ff76011
Target branch: net11.0 · State: open · Author: area owner (XAML/SourceGen)
Verdict: Needs changes — most round‑16 items are now resolved and the new commit is high‑quality, but the round‑16 line‑ending (CRLF/LF) failure in the ToDebugString_* tests still appears unaddressed at this head, and the head's Helix unit run has not yet completed.

Note: the branch was rebased since round‑16 (the previously reviewed f276fc7… is no longer in history; the current tip squashes the work into 7 commits ending at 7f0c8ee). Reconciliation below is against round‑16's two must‑fix items, evaluated against current head content.

Prior‑review reconciliation (round‑16 → now)

  • M‑updater E2E failures (MetadataUpdater.IsSupported is false) — resolved. SourceGen.UnitTests.csproj now sets <MetadataUpdaterSupport>true</MetadataUpdaterSupport> (with the explanatory comment), which makes IsSupported true under CoreCLR even in Release. The E2E helper's hard Assert.True(MetadataUpdater.IsSupported, …) should now pass.
  • CRLF/LF in XamlNodeDiff.ToDebugString testsnot fixed at head. ToDebugString() still builds output with sb.AppendLine() (→ Environment.NewLine = \r\n on Windows), while the 11+ XamlNodeDiffTests.ToDebugString_* cases assert literal "…\n…" via plain Assert.Equal. I grepped the test file at head: 0 occurrences of ReplaceLineEndings/Environment.NewLine/.Replace( — no normalization anywhere. The author's "All 413 SourceGen unit tests pass" reflects a macOS run (where NewLine == "\n"); this is exactly the host‑dependence round‑16 flagged and it will re‑fail on the Windows Helix leg. Fix: either emit '\n' explicitly in ToDebugString (e.g. sb.Append('\n') instead of AppendLine()), or normalize in the assertions.
  • Design items B1–B5 / M1–M13 — the changelog is thorough and the head bears it out: [FeatureGuard] switch, deterministic \n+sorted‑HashSet codegen (XamlNodeDiff.DiffChildrenWithMatching now sorts keys Ordinal), SymbolDisplay.FormatLiteral for resource‑key + string‑literal safety, registry [EditorBrowsable(Never)], all 7 PublicAPI.Unshipped.txt aligned, Track() reduced to a null‑guarded no‑op, registry instance index sourced from GetInstances.

New since round‑16 (this commit) — spot review

  • Handler now actually wired: [assembly: MetadataUpdateHandler(typeof(XamlIncrementalHotReloadHandler))] is uncommented and the type is now public [EditorBrowsable(Never)]. UpdateApplication still early‑returns when IsIncrementalHotReloadEnabled == false, so the live hot‑reload path remains opt‑in. M9 batch dispatch (single BeginInvokeOnMainThread) and M10 (registry‑sourced instances) look correct.
  • AOT/trim hazard removed (good): EmitContentPropertyChange no longer emits ((dynamic)parent).Content = … unconditionally — it emits a typed cast when the parent type resolves and only falls back to dynamic otherwise. This removes a Microsoft.CSharp dependency that is incompatible with NativeAOT/full trim. Sensible given the always‑on codegen path.
  • Event‑handler churn: new OldValue on PropertyDiff drives unsubscribe‑old/subscribe‑new, guarded by IsValidCSharpIdentifier. x:DataType‑change detection on the node itself (M7, DetectXDataTypeChange) correctly forces same‑node binding refresh. Logic reads sound.

CI status

  • 🟠 Windows Helix Unit Tests — the run linked from gh pr checks (build 1459135) was canceled/superseded when the current build was queued; its fail status is not a clean signal. The live build 1459322 is in progress and its Helix unit tests have not run yet, so there is no green Windows SourceGen result for this head. Combined with the unfixed ToDebugString CRLF mismatch, I expect the Windows SourceGen leg to re‑fail.
  • 🟡 AOT macOS/windows + RunOniOS TrimFull/CoreCLR — red in 1459322, but the AOT macOS log surfaces MSB4276 SDK‑resolver/harness noise rather than a feature‑specific compile error; consistent with round‑16's "harness‑level, not clearly attributable" classification. The feature is gated off by default and the dynamic→typed‑cast change is the right mitigation. Please confirm these are the usual pre‑existing reds and not a trim regression from the always‑on codegen before un‑gating.
  • ✅ Windows/macOS Debug+Release builds, Pack, and the bulk of integration legs (Blazor, MultiProject, Samples, RunOnAndroid, most RunOniOS) are green.

Blast radius

  • Runtime: opt‑in (RuntimeFeature.IsIncrementalHotReloadEnabled defaults false, [FeatureGuard], handler early‑returns, registry [EditorBrowsable(Never)]). Low risk for users who don't enable XIHR — but note the [assembly: MetadataUpdateHandler] is now always present, so the runtime will invoke UpdateApplication during metadata updates; the feature‑switch early‑return is the only behavioral gate. Worth a sanity check that a disabled‑XIHR app sees zero behavioral change under dotnet‑watch.
  • Build/codegen: the generated InitializeComponent()/UpdateComponent() emission runs for every XAML compile, so determinism/correctness is broad‑impact. The \n+sorted‑key work and FormatLiteral escaping are the right guards; the Windows test leg is the guardrail catching the residual ToDebugString host‑dependence.

Findings summary

  • Must‑fix: ToDebugString line endings (emit '\n' or normalize the asserts) so the Windows SourceGen suite is host‑independent.
  • Confirm before un‑gating: AOT/TrimFull reds are pre‑existing harness noise, not a trim regression from the gated/codegen paths; and a disabled‑XIHR app is behaviorally inert despite the now‑active [MetadataUpdateHandler].
  • Nice: the AOT dynamic removal, deterministic codegen, and registry/Track() simplification are solid improvements.

Confidence: high on the ToDebugString CRLF diagnosis (code + test read at head; 0 normalization sites) and on the MetadataUpdater resolution; medium on CI (current Helix run incomplete, prior build canceled) and on the AOT reds (not deep‑dived).

Automated non‑approval review. No human approval is implied or given; this comment does not gate merge and uses neither approve nor request‑changes.

…iew feedback

- Optimize MetadataUpdateHandler with a 2-level Type→instances table
  for fast lookup; add Style/Trigger/VSM/Behavior coverage.
- Skip the ICRuntime structural fallback when XIHR is enabled — the
  incremental path now covers the previously fallback-only cases.
- Binding cleanup, attached-property clear on remove, expression-binding
  reuse, event handler unsubscribe to avoid zombie bindings/handlers.
- Code dedup and factory-context plumbing.
- Resource safety/validation fixes and null-forgiving guards.

Address multi-model review feedback (B1-B5, M1-M13):

Blockers:
- B1 (partial): include TargetFramework in XamlHotReloadState cache key
  so per-TFM builds of the same assembly do not contaminate each other.
  (True Roslyn IncrementalGenerator pure-function compliance is tracked
  as a follow-up — requires IncrementalValueProvider re-architecture.)
- B2: align all 7 PublicAPI.Unshipped.txt files. Every TFM (except
  netstandard) now ships the handler entries; every TFM ships the
  resource-key API and the new FindStaticResource helper.
- B3: rewrite XamlGenerator's IHR entry so first-run seeding only fires
  when the cache has no entry (hadPreviousEntry==false). Previously an
  unrelated .cs edit could silently reset __version to 0 while patches
  were retained, mis-aligning runtime and generated state.
- B4: revert the literal "x" namespace comparisons. XamlParser
  .ParsePropertyName normalises every x: directive via XmlName.xName /
  xKey / xClass / xFieldModifier / xTypeArguments / xDataType — all
  constructed as new XmlName("x", "..."). The original code was
  correct; documented inline to prevent the same false-positive.
- B5: refactor UpdateComponentCodeWriter so each TryGet probe wraps its
  work in 'if (TryGet) { ... }' instead of 'if (!TryGet) return;'. A
  missing node now skips a single change and __version still advances;
  previously the whole UpdateComponent() short-circuited and the page
  was stranded at the old version, replaying the same failure forever.

Majors:
- M1: tag RuntimeFeature.IsIncrementalHotReloadEnabled with
  [FeatureGuard(RequiresUnreferencedCode/RequiresDynamicCode)] so the
  IL linker can prove the gated paths unreachable when XIHR is off.
- M2: emit "\n" (not RuntimeInformation-conditional) and sort
  HashSet enumerations before codegen so generator output is bit-
  deterministic across OS hosts (preserves Roslyn output caching).
- M3: tag XamlComponentRegistry [EditorBrowsable(Never)].
- M4: use SymbolDisplay.FormatLiteral for resource-key emission to
  prevent injection via crafted keys.
- M6: resolve the synthetic "_Content" diff key to the type's
  [ContentProperty] target before emission (root + child sites).
- M7: detect x:DataType change on the node itself (not only ancestors)
  so bindings on the same node refresh when their type context shifts.
- M8: emit XamlComponentRegistry.FindStaticResource(this, key) for
  StaticResource converter swaps. Walks the parent chain → Application
  → system resources, matching StaticResourceExtension.ProvideValue
  semantics. Previously this.Resources[key] only checked the page-
  level dictionary.
- M9: batch UpdateApplication's main-thread dispatches into a single
  BeginInvokeOnMainThread call per metadata update.
- M10: drop the handler's parallel weak-reference list.
  UpdateApplication now queries XamlComponentRegistry.GetInstances(type)
  directly, eliminating the 'register here / register there' double-
  bookkeeping. Track() is kept as a no-op for backward compatibility
  with already-compiled IC bodies.
- M11: ArgumentNullException guard on Track(null).
- M12: serialize RegisterResourceKeys' Remove+Add against the
  ConditionalWeakTable so concurrent registrations on the same page
  cannot race (netstandard2.0 has no AddOrUpdate).
- M13: optimise pure reorders to RemoveAt + Insert (preserves handler /
  animation / focus state of retained children). Falls back to Clear +
  re-Add when the change involves any Add or Remove.

All 413 SourceGen unit tests pass. 26/26 XamlComponentRegistry +
XamlIncrementalHotReload Core.UnitTests pass.

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

Second-round re-review fixes:
- M8 (correctness): emit FindStaticResource(parentAccessor, key) — the
  target element of the markup-extension property assignment — instead
  of FindStaticResource(this, ...). Walking from `this` (the page)
  skipped any ResourceDictionary scoped on intermediate parents
  (e.g., a ContentView's own <Resources>), causing valid StaticResource
  references to silently miss during hot reload.
- M4 (completion): replace UpdateComponentCodeWriter.EscapeString's
  hand-rolled C-escape table with Microsoft.CodeAnalysis.CSharp
  .SymbolDisplay.FormatLiteral. The previous implementation missed
  C0 control chars outside the named set (\u0001-\u0006, \u000e-\u001f,
  etc.), which would have produced invalid C# string literals for
  XAML values containing them.
- NEW (AOT): remove `((dynamic)parent).Content = ...` from
  EmitContentPropertyChange. The synthesised dynamic call requires
  Microsoft.CSharp and is incompatible with NativeAOT / full trim.
  Pass the parent type symbol and emit a typed cast; fall back to
  dynamic only if type resolution failed (preserved behaviour).
@StephaneDelcroix StephaneDelcroix force-pushed the feature/xaml-incremental-hotreload branch from 7f0c8ee to b260645 Compare June 12, 2026 09:21
@kubaflo

kubaflo commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

AI code review refresh for net11.0 target

Head reviewed: b260645ab9fa984384808a21165c3598071e2c5e
Target branch: net11.0 · State: open · Author: area owner (XAML/SourceGen)

Verdict: Needs changes — the new commit (b260645, "handler optimization, IC fallback gate, cleanup, and rev…") is genuinely good work and lands several correctness/trim wins, but two distinct SourceGen-unit-test failures are reproducible at this exact head, one of which I previously (round 17) believed resolved and is in fact still failing — on both platforms now.

Prior-review reconciliation (round 17 → now)

  • CRLF/LF in XamlNodeDiff.ToDebugString tests — STILL NOT FIXED. ToDebugString() still uses sb.AppendLine() (→ Environment.NewLine = \r\n on Windows) at XamlNodeDiff.cs (lines ~189, ~208), while the 11 XamlNodeDiffTests.ToDebugString_* cases assert literal "…\n…" via plain Assert.Equal. CI confirms it empirically: Windows Debug run 40404320 fails e.g. ToDebugString_DeeplyNested with Expected: …\n… / Actual: …\r\n…. Fix: emit '\n' explicitly in ToDebugString (sb.Append('\n') instead of AppendLine()), or normalize in the asserts.
  • MetadataUpdater.IsSupported is false (E2E) — REGRESSION vs my round-17 ✅. I marked this resolved in round 17 by inferring from the <MetadataUpdaterSupport>true</MetadataUpdaterSupport> csproj setting; the Windows Helix leg hadn't finished, so that ✅ was premature. With CI now complete, 5 XamlIncrementalHotReloadE2ETests.*_AppliedViaHotReload / ChainedPatches tests fail on BOTH macOS (40404322) and Windows (40404320) at AssertHotReloadSupported() (E2ETests.cs:209). The csproj flag is present at head but is not sufficient in the xUnit/Helix runner — on CoreCLR MetadataUpdater.IsSupported also requires the process to start with modifiable assemblies (DOTNET_MODIFIABLE_ASSEMBLIES=debug) / hot-reload-enabled launch. Fix: set that env var for the test run (runsettings/RuntimeHostConfigurationOption / Helix payload env), or make the E2E helper Skip when unsupported instead of Assert.True.
  • ✅ Deterministic \n+sorted-key codegen, SymbolDisplay.FormatLiteral escaping, registry [EditorBrowsable(Never)], all 7 PublicAPI.Unshipped.txt, dynamic→typed-cast AOT mitigation, [MetadataUpdateHandler] wiring + feature-switch early-return — all hold at head.

New since round 17 (spot review of b260645) — solid

  • TFM now part of the hot-reload state cache key (GetParsedRoot/GetNodeIds/GetVersion(asm, tfm, relPath)) — correctly prevents cross-TFM state collision in multi-targeted builds. Good catch.
  • emptyDiff out-param with an excellent doc-comment: formatting/comment-only edits no longer reset the version chain (avoids stranding live instances at if (__version == 0)). Sound.
  • FormatLiteral for resource keys, EnableDiagnostics codegen gated off when XIHR is enabled, RuntimeFeature [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] for trim correctness. All reasonable.

CI status (maui-pr build 1461045 — FAILED)

  • Windows Helix Unit Tests (Debug + Release) — fail. Debug leg = 17 failures: 11 × ToDebugString_* (CRLF) + 5 × E2E (IsSupported) + the SourceGen.UnitTests.dll work-item crash that follows.
  • macOS Unit Tests — the 5 E2E IsSupported failures reproduce here too (no ToDebugString failures on mac since NewLine == "\n"), confirming the MetadataUpdater issue is platform-independent, not Windows-only.
  • 🟡 AOT macOS/windows + RunOniOS TrimFull/CoreCLR — red, but the AOT macOS log (logId 1101) surfaces MSB4276 SDK-resolver noise (sdk/10.0.100/Sdks/... did not exist), i.e. harness/provisioning, not a feature compile error. Consistent with the usual pre-existing reds; the gated + typed-cast codegen is the right mitigation.
  • ✅ Windows/macOS Debug+Release builds, Pack, and the bulk of integration legs (templates, Blazor, MultiProject, Samples, RunOnAndroid, most RunOniOS) green.

Blast radius / failure-mode probing

  • Codegen runs for every XAML compile — TFM-keying and the diagnostics gate are broad-impact; the deterministic \n/sorted-key + FormatLiteral guards are the right protection. The two failing test buckets are test-side (host line-endings + runner hot-reload capability), not user-facing codegen correctness — but they block the SourceGen leg and must go green before merge.
  • Runtime stays opt-in (IsIncrementalHotReloadEnabled default false, [FeatureGuard], handler early-return). The [assembly: MetadataUpdateHandler] is always present, so UpdateApplication is invoked during metadata updates; the feature-switch early-return is the only behavioral gate — worth a final sanity check that a disabled-XIHR app under dotnet-watch is behaviorally inert.

Findings summary

  • Must-fix [Draft] Readme WIP #1: ToDebugString line endings (emit '\n' / normalize asserts) → host-independent Windows SourceGen leg.
  • Must-fix Update README.md #2: make MetadataUpdater.IsSupported true in the test runner (DOTNET_MODIFIABLE_ASSEMBLIES=debug env in the Helix/runsettings payload) or Skip the E2E tests when unsupported — currently failing on both platforms.
  • Confirm: AOT/TrimFull reds are the usual harness/SDK-resolver noise, not a trim regression from the always-on codegen.

Confidence: high on both must-fix items — they are reproduced by the completed CI test runs at this exact head (Windows 40404320, macOS 40404322) and corroborated by reading the code/tests at head; high on the AOT harness classification (MSB4276 confirmed in logId 1101). I explicitly correct my round-17 ✅ on MetadataUpdater: that was premature.

Automated non-approval review. No human approval is implied or given; this comment does not gate merge and uses neither approve nor request-changes.

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.

3 participants