diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
index 0083977834db..d3766c044a71 100644
--- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
+++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets
@@ -10,11 +10,13 @@
<_MauiXamlInflator Condition="' $(MauiXamlInflator)' != '' ">$(MauiXamlInflator)
<_MauiXamlInflator Condition=" '$(MauiXamlInflator)' == '' ">SourceGen
-
+
+ truefalse
diff --git a/src/Controls/src/SourceGen/GeneratorHelpers.cs b/src/Controls/src/SourceGen/GeneratorHelpers.cs
index 5534fa5aad8b..2376a673c39f 100644
--- a/src/Controls/src/SourceGen/GeneratorHelpers.cs
+++ b/src/Controls/src/SourceGen/GeneratorHelpers.cs
@@ -36,6 +36,35 @@ public static string EscapeIdentifier(string identifier)
: $"@{identifier}";
}
+ ///
+ /// A stable, deterministic 32-bit content hash (FNV-1a) of the XAML text, used as the
+ /// __version content identity for XAML Incremental Hot Reload. Unlike a monotonically
+ /// increasing counter (which depends on edit history held in mutable static state and makes the
+ /// generator non-deterministic), this value is a pure function of the current XAML content, so
+ /// identical content always yields the same identity — and a revert to earlier content restores
+ /// the earlier identity. Unlike it is not randomized per
+ /// process, so it is reproducible across builds/hosts. Returned as a non-negative int.
+ ///
+ public static int StableContentHash(string? content)
+ {
+ unchecked
+ {
+ const uint fnvOffset = 2166136261;
+ const uint fnvPrime = 16777619;
+ uint hash = fnvOffset;
+ if (content != null)
+ {
+ foreach (char c in content)
+ {
+ hash = (hash ^ (byte)(c & 0xFF)) * fnvPrime;
+ hash = (hash ^ (byte)((c >> 8) & 0xFF)) * fnvPrime;
+ }
+ }
+ // Fold to a non-negative int so it renders as a plain integer literal.
+ return (int)(hash & 0x7FFFFFFF);
+ }
+ }
+
public static ProjectItem? ComputeProjectItem((AdditionalText additionalText, AnalyzerConfigOptionsProvider optionsProvider) tuple, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
diff --git a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs
index 9f4f603d247a..1a8e2416e32c 100644
--- a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs
+++ b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs
@@ -97,7 +97,7 @@ PrePost newblock() =>
{
if (xamlItem.ProjectItem.EnableIncrementalHotReload)
{
- codeWriter.WriteLine("#pragma warning disable CS0414 // __version is read by UpdateComponent (generated on XAML edit)");
+ codeWriter.WriteLine("#pragma warning disable CS0414 // __version is a write-only content-identity marker (stamped by IC/UC, read by diagnostics/tooling)");
codeWriter.WriteLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
codeWriter.WriteLine("private int __version = 0;");
codeWriter.WriteLine("#pragma warning restore CS0414");
@@ -166,7 +166,7 @@ PrePost newblock() =>
codeWriter.WriteLine();
}
- // Emit Register calls and __version bump for incremental hot reload
+ // Emit Register calls and the content-identity stamp for incremental hot reload
if (nodeIds != null)
{
codeWriter.WriteLine();
@@ -216,11 +216,15 @@ PrePost newblock() =>
codeWriter.WriteLine($"global::Microsoft.Maui.Controls.Xaml.XamlComponentRegistry.RegisterResourceKeys(this, new string[] {{ {keysArray} }});");
}
- // Set __version to the latest version from state (so fresh instances skip all UC patches)
- var assemblyName = compilation.AssemblyName ?? string.Empty;
- var tfm = xamlItem.ProjectItem.TargetFramework ?? string.Empty;
- var latestVersion = XamlHotReloadState.GetVersion(assemblyName, tfm, xamlItem.ProjectItem.HotReloadStateKey);
- codeWriter.WriteLine($"__version = {latestVersion};");
+ // Stamp fresh instances with the deterministic content hash of the current XAML.
+ // UpdateComponent() stamps the SAME hash after it runs, so a freshly-created
+ // instance and a live (hot-reloaded) one converge on the same __version for
+ // identical content. This is a pure function of the current content —
+ // deterministic and revert-stable — unlike the old monotonic version counter,
+ // which depended on edit history held in mutable static state. The value is a
+ // write-only content-identity marker (not read for dispatch); it lets diagnostics
+ // and tooling recognize which XAML content an instance currently reflects.
+ codeWriter.WriteLine($"__version = {GeneratorHelpers.StableContentHash(xamlItem.Xaml)};");
codeWriter.WriteLine("global::Microsoft.Maui.Controls.Xaml.XamlIncrementalHotReloadHandler.Track(this);");
}
}
@@ -276,8 +280,9 @@ public static bool TryGetRootType(
}
///
- /// Generates a single patch body (the code for an if (__version == fromVersion) { ... } block)
- /// from two XAML versions. Returns when the diff is structural, empty, or on parse error.
+ /// Generates a single previous→current patch body (the statements that bring a live instance to
+ /// the current XAML) from two XAML versions. Returns when the diff is
+ /// structural, empty, or on parse error.
///
/// The cached parsed tree from the previous generation (may be null on first diff).
///
@@ -297,10 +302,9 @@ public static bool TryGetRootType(
///
///
/// Set to when the new XAML parsed cleanly but produced no semantic
- /// diff (e.g., a formatting / comment-only edit). Callers must NOT reset the version chain
- /// or clear accumulated patches in this case — doing so would strand live instances at the
- /// previous version when the next real edit emits if (__version == 0). Refresh the
- /// cached XAML text and parsed tree only.
+ /// diff (e.g., a formatting / comment-only edit, or a revert to the previous state). The caller
+ /// refreshes the cached XAML text and parsed tree only, and emits a present-but-empty
+ /// UpdateComponent() so the method never disappears between generations.
///
public static string? TryGeneratePatchBody(
SGRootNode? cachedOldRoot,
diff --git a/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs b/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs
index a2c957c38c3b..5cea9bdcd739 100644
--- a/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs
+++ b/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs
@@ -14,21 +14,28 @@
namespace Microsoft.Maui.Controls.SourceGen;
///
-/// Generates a single UpdateComponent() partial method containing accumulated
-/// if (__version == N) patch blocks from successive XAML Hot Reload edits.
+/// Generates a single UpdateComponent() partial method that applies one absolute
+/// previous→current patch for XAML Incremental Hot Reload.
///
///
///
-/// Per the spec, the generated method chains sequential if blocks (not else if):
+/// The method is emitted UNCONDITIONALLY (no if (__version == N) version-chain guard) and is
+/// present on EVERY generation — even the first compile and no-op edits — so a XIHR type never gains
+/// or loses the method across generations (that member churn is what crashes Roslyn's EnC delta
+/// tracking). Its body applies the current patch and then stamps __version with a deterministic
+/// content hash of the current XAML:
///
/// internal void UpdateComponent()
/// {
-/// if (__version == 0) { /* v0→v1 patch */ __version = 1; }
-/// if (__version == 1) { /* v1→v2 patch */ __version = 2; }
+/// /* absolute previous→current patch (property sets assign target values) */
+/// __version = 123456789; // stable content hash of the current XAML
+/// return;
/// }
///
-/// A v0 instance chains through ALL patches; a fresh instance (whose InitializeComponent
-/// sets __version to the latest) skips them all.
+/// Because patch property-sets are absolute, a single patch brings any live instance to the current
+/// state regardless of which edit it was last updated to, and a revert to earlier content collapses
+/// to the earlier patch/identity — deterministic, revert-stable output for identical XAML (no
+/// accumulated chain, no stale intermediate values). See the XIHR versioning determinism fix.
///
///
/// Property value encoding strategy (in priority order):
@@ -48,9 +55,14 @@ static class UpdateComponentCodeWriter
const string NewLine = "\n";
///
- /// Generates the code for a single if (__version == fromVersion) { ... __version = toVersion; } block.
- /// Returns when contains no changes.
+ /// Generates the statements that apply a single previous→current patch (property sets, child-list
+ /// changes, etc.), WITHOUT any if (__version == N) guard or __version assignment — the
+ /// caller () wraps this
+ /// body and stamps the content-hash identity. Returns when
+ /// contains no changes.
///
+ /// Vestigial: the monotonic version no longer drives dispatch (kept for the state/bookkeeping call chain and test signatures).
+ /// Vestigial: see .
/// ID dictionary for added nodes (from the new tree). May be null for tests.
public static string? GeneratePatchBody(
XamlTreeDiff diff,
@@ -130,11 +142,6 @@ static class UpdateComponentCodeWriter
codeWriter.WriteLine();
}
- // Always bump __version, even when individual TryGet probes missed. Skipping the bump
- // would strand the instance at fromVersion and force the same (already-failed) patch
- // to re-run on every subsequent UpdateComponent() invocation, never making progress.
- codeWriter.WriteLine($"__version = {toVersion};");
-
codeWriter.Flush();
return codeWriter.InnerWriter.ToString();
}
@@ -143,20 +150,31 @@ static class UpdateComponentCodeWriter
/// Assembles a complete UpdateComponent() source file from accumulated patch bodies.
/// Each patch body becomes an if (__version == N) { ... } block inside the single method.
///
- public static string? GenerateUpdateComponent(
+ ///
+ /// Generates the UpdateComponent() method body from a single baseline→current patch.
+ /// The patch is always emitted (even when is null/empty), so the
+ /// method never appears→disappears across generations — that member churn is what crashes Roslyn's
+ /// EnC delta tracking (dotnet/maui XIHR versioning fix). Because patch property-sets are absolute
+ /// (they assign target values, not relative deltas), a single baseline→current patch correctly brings
+ /// any live instance to the current state regardless of which edit it was last updated to, and a revert
+ /// to the baseline collapses to an empty patch — no accumulated version chain, no stale intermediate
+ /// values, deterministic output for identical XAML.
+ ///
+ public static string GenerateUpdateComponent(
INamedTypeSymbol rootType,
string accessModifier,
- List allPatchBodies,
- int startVersion = 0)
+ string? patchBody)
{
- if (allPatchBodies.Count == 0)
- return null;
-
using var codeWriter = new IndentedTextWriter(new StringWriter(CultureInfo.InvariantCulture), "\t") { NewLine = NewLine };
codeWriter.WriteLine(GeneratorHelpers.AutoGeneratedHeaderText);
codeWriter.WriteLine("#nullable enable");
codeWriter.WriteLine("#pragma warning disable CS0219 // Variable is assigned but its value is never used");
+ // A XAML class that derives from another XAML class emits its own UpdateComponent(), which
+ // intentionally hides the base's (each level patches its own XAML tree). Because UpdateComponent
+ // is now emitted on EVERY generation — not just on edit — this hiding surfaces at build time for
+ // inherited XAML; suppress the member-hiding warning in this generated file.
+ codeWriter.WriteLine("#pragma warning disable CS0108 // Member hides inherited member; missing new keyword");
codeWriter.WriteLine();
codeWriter.WriteLine($"namespace {rootType.ContainingNamespace};");
codeWriter.WriteLine();
@@ -167,21 +185,17 @@ static class UpdateComponentCodeWriter
codeWriter.WriteLine($"[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
codeWriter.WriteLine($"internal void UpdateComponent()");
+ // The method is ALWAYS emitted (member stability / no EnC churn), but its BODY is empty when
+ // this generation carries no XAML change (first compile, empty/reverted diff, structural
+ // reset). A non-empty body means the XAML actually changed. The SDK's MetadataUpdateHandler
+ // classifies a delta as a XAML change by inspecting whether this method's body is non-empty
+ // (its compiled IL is more than a trivial return) — an empty UpdateComponent() therefore reads
+ // as "not a XAML change", which is what keeps pure C#/code-behind edits out of the XAML-change
+ // signal while still guaranteeing the method never appears/disappears across generations.
using (PrePost.NewBlock(codeWriter))
{
- // Emit each patch as a sequential if (__version == N) block
- for (int i = 0; i < allPatchBodies.Count; i++)
- {
- var body = allPatchBodies[i];
- var version = startVersion + i;
- codeWriter.WriteLine($"if (__version == {version})");
- using (PrePost.NewBlock(codeWriter))
- {
- WriteIndentedBody(codeWriter, body);
- }
- }
-
- codeWriter.WriteLine("return;");
+ if (!string.IsNullOrWhiteSpace(patchBody))
+ WriteIndentedBody(codeWriter, patchBody!);
}
}
@@ -207,7 +221,7 @@ static class UpdateComponentCodeWriter
if (patchBody == null)
return null;
- return GenerateUpdateComponent(rootType, accessModifier, new List { patchBody }, startVersion: fromVersion);
+ return GenerateUpdateComponent(rootType, accessModifier, patchBody);
}
static void WriteIndentedBody(IndentedTextWriter codeWriter, string body)
@@ -312,7 +326,8 @@ static void EmitChildListChange(
bool hasAdded = false;
for (int i = 0; i < change.NewChildren.Count; i++)
{
- if (change.NewChildren[i].Kind == ChildChangeKind.Added) { hasAdded = true; break; }
+ if (change.NewChildren[i].Kind == ChildChangeKind.Added)
+ { hasAdded = true; break; }
}
bool pureReorder = !hasAdded && change.RemovedNodeIds.Count == 0;
@@ -1163,7 +1178,8 @@ static void TryEmitAttachedPropertyChange(
break;
}
}
- if (getter != null) break;
+ if (getter != null)
+ break;
current = current.BaseType!;
}
diff --git a/src/Controls/src/SourceGen/XamlGenerator.cs b/src/Controls/src/SourceGen/XamlGenerator.cs
index 9a6a00e960b3..e4aa5badee03 100644
--- a/src/Controls/src/SourceGen/XamlGenerator.cs
+++ b/src/Controls/src/SourceGen/XamlGenerator.cs
@@ -192,6 +192,16 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext)
// Incremental Hot Reload: compute the diff and update state BEFORE generating IC,
// so that IC can read the latest version from XamlHotReloadState and set __version correctly.
string? ucCode = null;
+ // Resolve the root type once. UpdateComponent() must be present on EVERY generation
+ // (first compile, no-op edits, unchanged rebuilds), so a XIHR type never gains or loses
+ // the method across generations — that member churn is what crashes Roslyn's EnC tracking.
+ INamedTypeSymbol? ucRootType = null;
+ string ucAccessModifier = "public";
+ bool canEmitUC = xamlItem.ProjectItem.EnableIncrementalHotReload
+ && xamlItem.Xaml is not null
+ && InitializeComponentCodeWriter.TryGetRootType(xamlItem, compilation, xmlnsCache, out ucRootType, out ucAccessModifier)
+ && ucRootType != null;
+ bool parseErrorOccurred = false;
var assemblyName = compilation.AssemblyName ?? string.Empty;
var targetFramework = xamlItem.ProjectItem.TargetFramework ?? string.Empty;
// Key the incremental-HR patch-chain state on the XAML file's absolute path (not its
@@ -212,9 +222,10 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext)
&& XamlHotReloadState.TryGetPrevious(assemblyName, targetFramework, stateKey, out previousXaml, out previousRoot, out previousNodeIds, out previousNextId, out previousVersion);
if (hadPreviousEntry
&& previousXaml != xamlItem.Xaml
- && InitializeComponentCodeWriter.TryGetRootType(xamlItem, compilation, xmlnsCache, out var rootType, out var accessModifier)
- && rootType != null)
+ && canEmitUC)
{
+ var rootType = ucRootType!;
+ var accessModifier = ucAccessModifier;
var patchBody = InitializeComponentCodeWriter.TryGeneratePatchBody(
previousRoot,
previousNodeIds,
@@ -240,45 +251,41 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext)
// New XAML is invalid — keep last-good state untouched. IC generation below
// will re-attempt parsing the same broken XAML, throw, and the outer catch
// will emit the parse-error diagnostic. No UC update for this iteration.
+ parseErrorOccurred = true;
}
else if (patchBody != null)
{
- var version = previousVersion + 1;
- // Append the new patch body and update state (with cached parsed tree + effective IDs) BEFORE IC generation
- XamlHotReloadState.Update(assemblyName, targetFramework, stateKey, xamlItem.Xaml!, parsedNewRoot, effectiveNewIds, newNextNodeId, version, patchBody);
- var allPatches = XamlHotReloadState.GetPatchBodies(assemblyName, targetFramework, stateKey);
-
- // Generate UC source (emitted after IC below)
- ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(rootType, accessModifier, allPatches);
+ // Emit ONLY the single previous->current patch. Patch property-sets are absolute
+ // (they assign target values, not relative deltas), so applying this one patch brings
+ // any live instance to the current state regardless of the edit it was last at. Do NOT
+ // accumulate a version chain: accumulation retained stale/invalid intermediate patches
+ // and produced non-deterministic, sometimes-uncompilable output when an edit was
+ // reverted (the invalid value lingered forever). See the XIHR versioning fix.
+ XamlHotReloadState.Update(assemblyName, targetFramework, stateKey, xamlItem.Xaml!, parsedNewRoot, effectiveNewIds, newNextNodeId, previousVersion + 1);
+ ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(rootType, accessModifier, patchBody);
}
else if (emptyDiff)
{
- // Round-3 fix: semantically empty diff (e.g., formatting / comment-only XAML edit).
- // DO NOT reset Version or clear PatchBodies — live instances at version N would be
- // stranded when the next real edit emits `if (__version == 0)`. Just refresh the
- // cached XAML text + parsed tree so future diffs compare against current text.
+ // Semantically empty diff (revert to the previous state, or a formatting/comment-only
+ // edit). Refresh the cached XAML text + parsed tree, and emit an EMPTY UpdateComponent()
+ // so the method stays present across generations (a disappearing UC looks like a
+ // type-removal delta and crashes Roslyn's EnC tracking).
XamlHotReloadState.Update(assemblyName, targetFramework, stateKey, xamlItem.Xaml!, parsedNewRoot, effectiveNewIds, newNextNodeId, previousVersion);
-
- // Round-4 fix: if patches already exist, re-emit the UC partial so it doesn't
- // transiently disappear from the compilation between two real edits. Metadata-update
- // tooling tolerates this poorly (it can look like a type-removal delta).
- var existingPatches = XamlHotReloadState.GetPatchBodies(assemblyName, targetFramework, stateKey);
- if (existingPatches.Count > 0)
- {
- ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(rootType, accessModifier, existingPatches);
- }
+ ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(rootType, accessModifier, (string?)null);
}
else
{
- // Structural change: update state with new XAML and parsed tree, reset version.
- // Assign fresh IDs for the new tree (reset counter since patches are cleared).
+ // Structural change: reset state to the new XAML with fresh IDs. Fresh instances get
+ // the new InitializeComponent; live instances can't be patched incrementally across a
+ // structural change, so UpdateComponent() is emitted empty (but still present).
Dictionary? freshIds = null;
int freshNextId = 0;
if (parsedNewRoot != null)
{
freshIds = NodeIdHelper.AssignIds(parsedNewRoot, 0, out freshNextId);
}
- XamlHotReloadState.UpdateAndClearPatches(assemblyName, targetFramework, stateKey, xamlItem.Xaml!, parsedNewRoot, freshIds, freshNextId, 0);
+ XamlHotReloadState.Update(assemblyName, targetFramework, stateKey, xamlItem.Xaml!, parsedNewRoot, freshIds, freshNextId, 0);
+ ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(rootType, accessModifier, (string?)null);
}
}
else if (!hadPreviousEntry && xamlItem.ProjectItem.EnableIncrementalHotReload && xamlItem.Xaml is not null)
@@ -309,6 +316,15 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext)
}
// else: cache exists and XAML unchanged (or rootType lookup failed). Leave state untouched.
+ // Always-emit: if a UC was not produced above (first compile, no-op edit, or unchanged
+ // rebuild) but this is a XIHR-enabled type whose root resolved, emit an EMPTY
+ // UpdateComponent() so the method is present from the very first generation and never
+ // appears/disappears across generations (Roslyn EnC member-stability requirement).
+ if (ucCode == null && canEmitUC && !parseErrorOccurred)
+ {
+ ucCode = UpdateComponentCodeWriter.GenerateUpdateComponent(ucRootType!, ucAccessModifier, (string?)null);
+ }
+
// Generate IC — reads latest version from XamlHotReloadState
var code = InitializeComponentCodeWriter.GenerateInitializeComponent(xamlItem, compilation, sourceProductionContext, xmlnsCache, typeCache);
sourceProductionContext.AddSource(GetHintName(xamlItem.ProjectItem, "xsg"), code);
diff --git a/src/Controls/src/SourceGen/XamlHotReloadState.cs b/src/Controls/src/SourceGen/XamlHotReloadState.cs
index a53c53023ab1..57471b4152db 100644
--- a/src/Controls/src/SourceGen/XamlHotReloadState.cs
+++ b/src/Controls/src/SourceGen/XamlHotReloadState.cs
@@ -8,15 +8,25 @@
namespace Microsoft.Maui.Controls.SourceGen;
///
-/// In-process static cache that tracks the last-generated XAML text, version number, and
-/// accumulated patch bodies per file, enabling the incremental hot reload pipeline to
-/// compute diffs between XAML edits and emit a single UpdateComponent() with all patches.
+/// In-process static cache that tracks the last-generated XAML text (and its parsed tree / node IDs)
+/// per file, enabling the incremental hot reload pipeline to compute a diff between successive XAML
+/// edits and emit a single previous→current UpdateComponent() patch.
///
///
-/// This class uses mutable static state intentionally: Roslyn incremental generators run
-/// in-process during a build session, so state persists across incremental builds. Each entry
-/// maps a (AssemblyName, TargetFramework, RelativePath) tuple to the XAML content,
-/// version counter, and the list of accumulated patch bodies (each an if (__version == N) block).
+/// This class uses mutable static state intentionally: Roslyn incremental generators run in-process
+/// during a build session, so state persists across incremental builds. Each entry maps a
+/// (AssemblyName, TargetFramework, RelativePath) tuple to the cached XAML content and its
+/// parsed tree.
+///
+///
+/// IMPORTANT (XIHR determinism): the cache holds only what is needed to diff the PREVIOUS generation
+/// against the CURRENT one. It deliberately does NOT accumulate a growing chain of patch bodies, and
+/// generated output never embeds the counter — the emitted
+/// __version is a deterministic content hash of the current XAML. This keeps the generator's
+/// output a pure function of the current content: identical XAML always produces identical output, and
+/// reverting an edit restores the earlier output. (Accumulating patches / embedding a monotonic counter
+/// is exactly what made the generator non-deterministic and could leave reverted code uncompilable.)
+///
///
/// Keyed on (AssemblyName, TargetFramework, RelativePath) to prevent:
///
@@ -55,12 +65,13 @@ internal sealed class CacheEntry
/// to avoid colliding with existing IDs.
///
public int NextNodeId { get; set; }
- public int Version { get; set; }
///
- /// Accumulated patch bodies. Each entry is the code for one if (__version == N) { ... } block.
- /// On structural change, this list is cleared.
+ /// A monotonic generation counter, kept purely for internal bookkeeping/diagnostics. It does
+ /// NOT drive code generation — the emitted __version is a content hash, and dispatch is
+ /// unconditional — so its value never leaks into generated output. Retained so tooling can tell
+ /// how many times a file has been regenerated in the current build session.
///
- public List PatchBodies { get; } = new();
+ public int Version { get; set; }
}
///
@@ -91,32 +102,11 @@ public static bool TryGetPrevious(string assemblyName, string targetFramework, s
}
///
- /// Stores (or replaces) the current XAML text, parsed root, and version for the given file,
- /// and appends a patch body if provided.
- ///
- public static void Update(string assemblyName, string targetFramework, string relativePath, string xamlText, SGRootNode? parsedRoot, Dictionary? nodeIds, int nextNodeId, int version, string? patchBody = null)
- {
- lock (_lock)
- {
- if (!_cache.TryGetValue((assemblyName, targetFramework, relativePath), out var entry))
- {
- entry = new CacheEntry();
- _cache[(assemblyName, targetFramework, relativePath)] = entry;
- }
- entry.XamlText = xamlText;
- entry.ParsedRoot = parsedRoot;
- entry.NodeIds = nodeIds;
- entry.NextNodeId = nextNodeId;
- entry.Version = version;
- if (patchBody != null)
- entry.PatchBodies.Add(patchBody);
- }
- }
-
- ///
- /// Stores the XAML text, parsed root, and version, and clears all accumulated patches (structural change).
+ /// Stores (or replaces) the current XAML text, parsed root, node IDs, and version for the given
+ /// file. This fully replaces the previous entry — the cache only ever holds the latest generation,
+ /// never an accumulated history.
///
- public static void UpdateAndClearPatches(string assemblyName, string targetFramework, string relativePath, string xamlText, SGRootNode? parsedRoot, Dictionary? nodeIds, int nextNodeId, int version)
+ public static void Update(string assemblyName, string targetFramework, string relativePath, string xamlText, SGRootNode? parsedRoot, Dictionary? nodeIds, int nextNodeId, int version)
{
lock (_lock)
{
@@ -130,18 +120,6 @@ public static void UpdateAndClearPatches(string assemblyName, string targetFrame
entry.NodeIds = nodeIds;
entry.NextNodeId = nextNodeId;
entry.Version = version;
- entry.PatchBodies.Clear();
- }
- }
-
- ///
- /// Returns the current version for the given file, or 0 if not cached.
- ///
- public static int GetVersion(string assemblyName, string targetFramework, string relativePath)
- {
- lock (_lock)
- {
- return _cache.TryGetValue((assemblyName, targetFramework, relativePath), out var entry) ? entry.Version : 0;
}
}
@@ -171,19 +149,6 @@ public static int GetVersion(string assemblyName, string targetFramework, string
}
}
- ///
- /// Returns a copy of the accumulated patch bodies for the given file.
- ///
- public static List GetPatchBodies(string assemblyName, string targetFramework, string relativePath)
- {
- lock (_lock)
- {
- if (_cache.TryGetValue((assemblyName, targetFramework, relativePath), out var entry))
- return new List(entry.PatchBodies);
- return new List();
- }
- }
-
///
/// Clears all cached entries. Intended for use in tests that need to reset state between runs.
///
diff --git a/src/Controls/src/Xaml/HotReload/XamlIncrementalHotReloadHandler.cs b/src/Controls/src/Xaml/HotReload/XamlIncrementalHotReloadHandler.cs
index a27baa09c377..6b23c6f5fac7 100644
--- a/src/Controls/src/Xaml/HotReload/XamlIncrementalHotReloadHandler.cs
+++ b/src/Controls/src/Xaml/HotReload/XamlIncrementalHotReloadHandler.cs
@@ -71,11 +71,16 @@ public static void UpdateApplication(Type[]? updatedTypes)
// building and main-thread dispatch latency, not just the UI-thread invoke loop.
var sw = Stopwatch.StartNew();
- // Batch dispatch — collect ALL (instance, method, type) tuples across every updated
- // type, then issue a single MainThread.BeginInvokeOnMainThread that iterates them.
- // handledTypes records every recognized incremental-XAML type (one carrying a generated
- // UpdateComponent()), independent of whether it currently has live instances — this is the
- // synchronous "what kind of update is this" signal surfaced on UpdateRequested/UpdateSkipped.
+ // Batch dispatch — collect ALL (instance, method, type) tuples across every updated type whose
+ // XAML actually changed in this delta, then issue a single MainThread.BeginInvokeOnMainThread.
+ //
+ // handledTypes is the synchronous, pre-dispatch "this is a XAML change" signal tooling reads.
+ // UpdateComponent() is now emitted on EVERY XAML type (member stability), so its mere presence no
+ // longer distinguishes a XAML edit from a pure C#/code-behind edit. Instead we look at whether the
+ // method's BODY is non-empty: the generator emits an EMPTY UpdateComponent() when a generation
+ // carries no XAML change, and a patched (non-empty) one when the XAML changed. A pure C# edit does
+ // not regenerate the XAML code, so UpdateComponent() stays empty for that page — correctly read as
+ // "not a XAML change" — while the method still never appears/disappears across generations.
var dispatchBatch = new List<(object Instance, MethodInfo Method, Type Type)>();
var handledTypes = new List();
@@ -91,14 +96,14 @@ public static void UpdateApplication(Type[]? updatedTypes)
#pragma warning restore IL2070, IL2075
if (ucMethod is null)
- continue;
+ continue; // not an incremental-XAML type at all
+
+ if (IsEmptyUpdateComponent(ucMethod))
+ continue; // XAML unchanged in this delta (e.g. a pure C# edit) — not a XAML change
handledTypes.Add(type);
var instances = XamlComponentRegistry.GetInstances(type);
- if (instances.Count == 0)
- continue;
-
foreach (var instance in instances)
dispatchBatch.Add((instance, ucMethod, type));
}
@@ -155,5 +160,38 @@ public static void UpdateApplication(Type[]? updatedTypes)
HotReloadDiagnostics.OnUpdateApplied(typesArray, instanceCount, fromVersion, toVersion, sw.Elapsed);
});
}
+
+ // The generator emits an EMPTY UpdateComponent() body when a generation carries no XAML change, and a
+ // patched (non-empty) one when the XAML changed. An empty method compiles to a trivial body (just a
+ // return / a couple of nops), so its IL is only a few bytes; any real patch is far larger. This lets
+ // the handler distinguish a XAML change from a pure C#/code-behind edit without adding or removing the
+ // method across generations. Unlike reflecting a generated method's return value, UpdateComponent() is
+ // always part of a XAML-change delta (it is what applies Hot Reload), so its body is reliably current.
+ const int EmptyUpdateComponentMaxIL = 8;
+
+ // GetMethodBody() is [RequiresUnreferencedCode] (IL2026). The trimmer/ILC honors
+ // UnconditionalSuppressMessage (a #pragma only silences the Roslyn analyzer, not the publish-time
+ // trim/AOT warning). This is safe: XIHR is a dev-time (Hot Reload) feature gated by
+ // RuntimeFeature.IsIncrementalHotReloadEnabled, which is off for Release/publish, so this method is
+ // never reached under trimming/AOT — the "trimming may change method bodies" caveat cannot apply.
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
+ Justification = "XIHR is a dev-time feature disabled under trimming/AOT (Release); IsEmptyUpdateComponent is never reached there.")]
+ static bool IsEmptyUpdateComponent(MethodInfo ucMethod)
+ {
+ try
+ {
+ var body = ucMethod.GetMethodBody();
+ var il = body?.GetILAsByteArray();
+ return il is null || il.Length <= EmptyUpdateComponentMaxIL;
+ }
+#pragma warning disable CA1031
+ catch
+ {
+ // If the IL cannot be inspected on this runtime, fall back to treating the update as a XAML
+ // change (the pre-regression behavior) rather than silently dropping it.
+ return false;
+ }
+#pragma warning restore CA1031
+ }
}
#endif
diff --git a/src/Controls/tests/SourceGen.UnitTests/UpdateComponentCodeWriterTests.cs b/src/Controls/tests/SourceGen.UnitTests/UpdateComponentCodeWriterTests.cs
index ab620dc1ae2c..8d6379446674 100644
--- a/src/Controls/tests/SourceGen.UnitTests/UpdateComponentCodeWriterTests.cs
+++ b/src/Controls/tests/SourceGen.UnitTests/UpdateComponentCodeWriterTests.cs
@@ -49,7 +49,8 @@ static SGRootNode Parse(string xaml) =>
newRoot.Accept(new XamlNodeVisitor((node, parent) => node.Parent = parent), null);
var diff = XamlNodeDiff.ComputeDiff(oldRoot, newRoot);
- if (diff == null) return null; // structural change
+ if (diff == null)
+ return null; // structural change
if (rootType == null)
{
@@ -112,7 +113,8 @@ public void ChildAdd_ProducesUCWithChildListChange()
// Child add → UC generated with child list change handling
var result = Generate(v1, v2);
Assert.NotNull(result);
- Assert.Contains("__version == 1", result!, StringComparison.Ordinal);
+ // New design: a single unconditional patch — no `if (__version == N)` version-chain guard.
+ Assert.DoesNotContain("if (__version ==", result!, StringComparison.Ordinal);
// No goto fallback label or fallback block
Assert.DoesNotContain("goto fallback", result!, StringComparison.Ordinal);
Assert.DoesNotContain("fallback:", result!, StringComparison.Ordinal);
@@ -132,15 +134,17 @@ public void SinglePropertyChange_GeneratesMethodHeader()
}
[Fact]
- public void SinglePropertyChange_ContainsVersionGuard()
+ public void SinglePropertyChange_AppliesPatchUnconditionally()
{
var v1 = $"";
var v2 = $"";
var result = Generate(v1, v2);
Assert.NotNull(result);
- // Per spec: if (__version == fromVersion) { ... } — uses == not !=
- Assert.Contains("if (__version == 1)", result, System.StringComparison.Ordinal);
+ // New design: the single baseline->current patch is applied unconditionally (patch property-sets
+ // are absolute), with no accumulated `if (__version == N)` version-chain guard.
+ Assert.DoesNotContain("if (__version ==", result!, System.StringComparison.Ordinal);
+ Assert.Contains("World", result!, System.StringComparison.Ordinal);
}
[Fact]
@@ -156,14 +160,17 @@ public void SinglePropertyChange_NoFallbackLabel()
}
[Fact]
- public void SinglePropertyChange_ContainsVersionBump()
+ public void SinglePropertyChange_ProducesNonEmptyPatchBody()
{
var v1 = $"";
var v2 = $"";
var result = Generate(v1, v2);
Assert.NotNull(result);
- Assert.Contains("__version = 2;", result, System.StringComparison.Ordinal);
+ // A real XAML change yields a non-empty UpdateComponent() body (the patch), which is exactly the
+ // signal the runtime uses to classify the delta as a XAML change.
+ Assert.Contains("\"World\"", result, System.StringComparison.Ordinal);
+ Assert.Contains("XamlComponentRegistry.TryGet(this,", result, System.StringComparison.Ordinal);
}
[Fact]
@@ -192,17 +199,18 @@ public void SinglePropertyChange_NewValueInOutput()
}
[Fact]
- public void CustomVersions_MethodNameIncludesVersions()
+ public void SingleUpdateComponent_NoVersionedMethodName()
{
var v1 = $"";
var v2 = $"";
var result = Generate(v1, v2, fromVersion: 5, toVersion: 6);
Assert.NotNull(result);
- // Single UpdateComponent() method with if (__version == 5) guard
+ // New design: a single unconditional UpdateComponent() with no `if (__version == N)` guard and
+ // no version-suffixed method name.
Assert.Contains("void UpdateComponent()", result, System.StringComparison.Ordinal);
- Assert.Contains("if (__version == 5)", result, System.StringComparison.Ordinal);
- Assert.Contains("__version = 6;", result, System.StringComparison.Ordinal);
+ Assert.DoesNotContain("if (__version ==", result, System.StringComparison.Ordinal);
+ Assert.DoesNotContain("UpdateComponent_v", result, System.StringComparison.Ordinal);
}
[Fact]
diff --git a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadCSharpExpressionTests.cs b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadCSharpExpressionTests.cs
index fccbe0ef9036..1ac889fac3f5 100644
--- a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadCSharpExpressionTests.cs
+++ b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadCSharpExpressionTests.cs
@@ -308,7 +308,7 @@ public void CSharpExpression_ConcatenationChange_GeneratesUC()
}
[Fact]
- public void CSharpExpression_IdenticalXaml_NoUCGenerated()
+ public void CSharpExpression_IdenticalXaml_EmitsEmptyUC()
{
var xaml =
"""
@@ -324,8 +324,11 @@ public void CSharpExpression_IdenticalXaml_NoUCGenerated()
RunGenerator(xaml, ViewModelCode, enableIncrementalHotReload: true);
var (result, _) = RunGenerator(xaml, ViewModelCode, enableIncrementalHotReload: true);
+ // UC is always emitted (present-but-empty for unchanged XAML) so the method never disappears.
var ucSource = GetUCSource(result);
- Assert.Null(ucSource);
+ Assert.NotNull(ucSource);
+ Assert.Contains("internal void UpdateComponent()", ucSource!, StringComparison.Ordinal);
+ Assert.DoesNotContain("XamlComponentRegistry", ucSource!, StringComparison.Ordinal);
}
[Fact]
@@ -406,7 +409,8 @@ public void CSharpExpression_NewElementAdded_GeneratesUC()
// if UC is null, the fallback full-reload path handles it
if (ucSource is not null)
{
- Assert.Contains("__version == 0", ucSource, StringComparison.Ordinal);
+ // New design: UpdateComponent() is emitted (present) but carries no version-chain guard.
+ Assert.DoesNotContain("if (__version ==", ucSource, StringComparison.Ordinal);
}
}
@@ -452,7 +456,7 @@ public partial class TestPage : ContentPage
var ucSource = GetUCSource(result);
Assert.NotNull(ucSource);
- Assert.Contains("__version == 0", ucSource, StringComparison.Ordinal);
+ Assert.DoesNotContain("if (__version ==", ucSource!, StringComparison.Ordinal);
}
[Fact]
diff --git a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs
index 624e5526df81..7479701c37f1 100644
--- a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs
+++ b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs
@@ -25,6 +25,7 @@
using Microsoft.CodeAnalysis.Emit;
using Microsoft.Maui.Controls.SourceGen;
using Microsoft.Maui.Controls.Xaml;
+using Microsoft.Maui.Controls.Xaml.Diagnostics;
using Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen;
using Xunit;
@@ -379,21 +380,528 @@ public void MultiplePropertyChanges_ChainedPatches()
""";
- // Run source gen: V1 seeds, V2 produces UC with patch v0→v1
+ // Run source gen: V1 seeds, V2 produces UC with the single V1->V2 patch (no version chain)
var (icV1, icV2, ucV2) = RunSourceGenTwoPhase(xamlV1, xamlV2);
Assert.NotNull(ucV2);
- // Verify UC contains the expected version guard
- Assert.Contains("__version == 0", ucV2!, StringComparison.Ordinal);
- Assert.Contains("__version = 1", ucV2!, StringComparison.Ordinal);
+ // New design: a single unconditional patch — no accumulated `if (__version == N)` version guard.
+ Assert.DoesNotContain("if (__version ==", ucV2!, StringComparison.Ordinal);
- // Verify UC contains the new property value
+ // Verify UC contains the new property values
Assert.Contains("\"World\"", ucV2!, StringComparison.Ordinal);
Assert.Contains("\"V2\"", ucV2!, StringComparison.Ordinal);
}
+ ///
+ /// Runs the generator over three successive XAML versions against the same compilation, so
+ /// accumulates exactly as it would across live edits, and
+ /// returns the final (V3) IC + UC sources.
+ ///
+ (string icV3, string? ucV3) RunSourceGenThreePhase(string xamlV1, string xamlV2, string xamlV3)
+ {
+ var compilation = SourceGeneratorDriver.CreateMauiCompilation(AssemblyName);
+ var fileV1 = MakeFile(xamlV1);
+ var fileV2 = MakeFile(xamlV2);
+ var fileV3 = MakeFile(xamlV3);
+
+ Microsoft.CodeAnalysis.ISourceGenerator generator = new XamlGenerator().AsSourceGenerator();
+ var options = new Microsoft.CodeAnalysis.GeneratorDriverOptions(
+ disabledOutputs: Microsoft.CodeAnalysis.IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true);
+
+ Microsoft.CodeAnalysis.GeneratorDriver driver = CSharpGeneratorDriver.Create([generator], driverOptions: options)
+ .AddAdditionalTexts(System.Collections.Immutable.ImmutableArray.Create(fileV1.Text))
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV1]));
+
+ driver = driver.RunGenerators(compilation);
+ driver = driver
+ .ReplaceAdditionalText(fileV1.Text, fileV2.Text)
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV2]))
+ .RunGenerators(compilation);
+ driver = driver
+ .ReplaceAdditionalText(fileV2.Text, fileV3.Text)
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV3]))
+ .RunGenerators(compilation);
+
+ var run3 = driver.GetRunResult();
+ string? icV3 = null, ucV3 = null;
+ foreach (var gen in run3.Results)
+ {
+ foreach (var src in gen.GeneratedSources)
+ {
+ if (src.HintName.EndsWith(".xsg.cs", StringComparison.OrdinalIgnoreCase)
+ && !src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase))
+ icV3 = src.SourceText.ToString();
+ if (src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase))
+ ucV3 = src.SourceText.ToString();
+ }
+ }
+
+ Assert.NotNull(icV3);
+ return (icV3!, ucV3);
+ }
+
+ ///
+ /// Regression for the XIHR versioning determinism bug (Tomas Matousek). Editing a property to an
+ /// INVALID value and then reverting it must leave the generator in a state where the generated
+ /// output for the (now identical to baseline) XAML compiles cleanly and does not retain the
+ /// invalid intermediate value. Today's monotonic __version chain accumulates every patch, so the
+ /// invalid "Level22" block lingers in UpdateComponent() and the output fails to compile.
+ ///
+ [Fact]
+ public void RevertToOriginal_ProducesCompilableOutput_WithoutStalePatch()
+ {
+ XamlHotReloadState.Reset();
+
+ string Page(string headingLevel) => $$"""
+
+
+
+
+ """;
+
+ // V1 (valid) -> V2 (invalid enum member 'Level22') -> V3 (revert, identical to V1).
+ var (icV3, ucV3) = RunSourceGenThreePhase(Page("Level2"), Page("Level22"), Page("Level2"));
+
+ // The invalid intermediate value must NOT survive into the reverted generation.
+ if (ucV3 is not null)
+ Assert.DoesNotContain("Level22", ucV3, StringComparison.Ordinal);
+ Assert.DoesNotContain("Level22", icV3, StringComparison.Ordinal);
+
+ // And the final generated code (IC + UC) must compile — the whole point of reverting.
+ var sources = new List { PageStub, icV3 };
+ if (ucV3 is not null)
+ sources.Add(StripGeneratedCodeAttribute(ucV3));
+
+ var trees = sources.Select((s, i) =>
+ CSharpSyntaxTree.ParseText(s, path: $"Source{i}.cs", encoding: System.Text.Encoding.UTF8)).ToArray();
+ var comp = CreateMauiCompilation(trees);
+ var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray();
+ Assert.True(errors.Length == 0,
+ $"Reverted generation should compile, but got:\n{string.Join("\n", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}");
+ }
+
+ ///
+ /// Runs the generator over three successive XAML versions against the same compilation and
+ /// returns the IC + UC source for EVERY phase, so a test can reason about how the generated
+ /// content identity (__version) and patches evolve across edits — including reverts.
+ ///
+ (string icV1, string? ucV1, string icV2, string? ucV2, string icV3, string? ucV3)
+ RunSourceGenAllPhases(string xamlV1, string xamlV2, string xamlV3)
+ {
+ var compilation = SourceGeneratorDriver.CreateMauiCompilation(AssemblyName);
+ var fileV1 = MakeFile(xamlV1);
+ var fileV2 = MakeFile(xamlV2);
+ var fileV3 = MakeFile(xamlV3);
+
+ Microsoft.CodeAnalysis.ISourceGenerator generator = new XamlGenerator().AsSourceGenerator();
+ var options = new Microsoft.CodeAnalysis.GeneratorDriverOptions(
+ disabledOutputs: Microsoft.CodeAnalysis.IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true);
+
+ Microsoft.CodeAnalysis.GeneratorDriver driver = CSharpGeneratorDriver.Create([generator], driverOptions: options)
+ .AddAdditionalTexts(System.Collections.Immutable.ImmutableArray.Create(fileV1.Text))
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV1]));
+
+ driver = driver.RunGenerators(compilation);
+ var (icV1, ucV1) = ExtractICUC(driver.GetRunResult());
+
+ driver = driver
+ .ReplaceAdditionalText(fileV1.Text, fileV2.Text)
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV2]))
+ .RunGenerators(compilation);
+ var (icV2, ucV2) = ExtractICUC(driver.GetRunResult());
+
+ driver = driver
+ .ReplaceAdditionalText(fileV2.Text, fileV3.Text)
+ .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV3]))
+ .RunGenerators(compilation);
+ var (icV3, ucV3) = ExtractICUC(driver.GetRunResult());
+
+ return (icV1, ucV1, icV2, ucV2, icV3, ucV3);
+ }
+
+ static (string ic, string? uc) ExtractICUC(Microsoft.CodeAnalysis.GeneratorDriverRunResult run)
+ {
+ string? ic = null, uc = null;
+ foreach (var gen in run.Results)
+ {
+ foreach (var src in gen.GeneratedSources)
+ {
+ if (src.HintName.EndsWith(".xsg.cs", StringComparison.OrdinalIgnoreCase)
+ && !src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase))
+ ic = src.SourceText.ToString();
+ if (src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase))
+ uc = src.SourceText.ToString();
+ }
+ }
+ Assert.NotNull(ic);
+ return (ic!, uc);
+ }
+
+ ///
+ /// Determinism + reverse-transition (Tomas Matousek's determinism principle + Kirill's revert
+ /// requirement). The generated content identity (__version) is a pure function of the
+ /// current XAML content — so a revert to identical content restores the identical identity, not
+ /// an ever-growing counter. And the reverse edit's UpdateComponent() carries an absolute
+ /// patch that restores the baseline value on live instances.
+ ///
[Fact]
- public void IdenticalXaml_NoUCGenerated()
+ public void ContentHash_IsDeterministic_And_RevertRestoresIdentity()
+ {
+ XamlHotReloadState.Reset();
+
+ string Page(string text) => $$"""
+
+
+
+
+ """;
+
+ var v1 = Page("Hello");
+ var v2 = Page("World");
+ int h1 = GeneratorHelpers.StableContentHash(v1);
+ int h2 = GeneratorHelpers.StableContentHash(v2);
+ Assert.NotEqual(h1, h2); // different content => different identity
+
+ // V1 -> V2 -> V1 (revert). Reverting to identical content must restore the identical identity.
+ var (icV1, _, icV2, ucV2, icV3, ucV3) = RunSourceGenAllPhases(v1, v2, v1);
+
+ // Fresh instances stamp the content hash of their own generation.
+ Assert.Contains($"__version = {h1};", icV1, StringComparison.Ordinal);
+ Assert.Contains($"__version = {h2};", icV2, StringComparison.Ordinal);
+ // Determinism: the reverted generation (byte-identical to V1) reproduces V1's identity exactly —
+ // NOT h2+1 or any history-dependent value. This is the property the old monotonic counter broke.
+ Assert.Contains($"__version = {h1};", icV3, StringComparison.Ordinal);
+
+ // Forward edit's UC applies the new value (its non-empty body is also the XAML-change signal)...
+ Assert.NotNull(ucV2);
+ Assert.Contains("\"World\"", ucV2!, StringComparison.Ordinal);
+
+ // ...and the reverse edit's UC brings it back to the V1 value (reverse transition), without
+ // retaining the intermediate "World".
+ Assert.NotNull(ucV3);
+ Assert.Contains("\"Hello\"", ucV3!, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"World\"", ucV3!, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The strongest determinism guarantee (Tomas Matousek's purity principle): the generated
+ /// InitializeComponent for a given XAML must be BYTE-IDENTICAL regardless of how that
+ /// content was reached. Both sources here come from the SAME generator driver/options, so edit
+ /// history is the only variable: phase 1 (icV1) generates V1 from a clean state; phase 3
+ /// (icV3) reaches byte-identical V1 content by reverting an edit (V1→V2→V1). If any embedded
+ /// value (registry node IDs, the __version content hash, etc.) depended on edit history, the
+ /// two would differ; they must not. Covers a property-only edit AND a structural edit (added child).
+ ///
+ [Theory]
+ [InlineData("", "")] // property-only edit
+ [InlineData("", "")] // structural edit (added child)
+ public void RevertedGeneration_InitializeComponent_IsByteIdentical_ToInitialGeneration(string bodyV1, string bodyV2)
+ {
+ string Page(string body) => $$"""
+
+
+
+
+ {{body}}
+
+
+ """;
+
+ var v1 = Page(bodyV1);
+ var v2 = Page(bodyV2);
+
+ XamlHotReloadState.Reset();
+ var (icV1, _, _, _, icV3, _) = RunSourceGenAllPhases(v1, v2, v1);
+
+ // InitializeComponent for identical content must be identical regardless of edit history —
+ // every value it embeds is a pure function of the current content, not of the path taken.
+ Assert.Equal(icV1, icV3);
+ }
+
+ ///
+ /// Regression for the inherited-XAML build break introduced by always-emitting UpdateComponent():
+ /// a XAML class that derives from another class exposing an internal UpdateComponent() emits
+ /// its own UpdateComponent(), which hides the base's (CS0108). Since UpdateComponent() is now
+ /// emitted on every generation — not just on edit — this must compile cleanly. The generated UC file
+ /// suppresses CS0108 (the hiding is intentional: each level patches its own XAML tree).
+ ///
+ [Fact]
+ public void UpdateComponent_OnInheritedXamlClass_CompilesWithoutHidingWarning()
+ {
+ XamlHotReloadState.Reset();
+
+ const string xaml = """
+
+
+
+
+ """;
+
+ var (ic, uc) = RunSourceGen(xaml);
+ Assert.NotNull(ic);
+ Assert.NotNull(uc); // always-emit: UC present even without an edit
+
+ // Code-behind: MainPage derives from a base that ALSO declares an internal UpdateComponent().
+ const string stub = """
+ namespace TestE2EApp;
+
+ public partial class BaseXamlPage : global::Microsoft.Maui.Controls.ContentPage
+ {
+ internal void UpdateComponent() { }
+ }
+
+ public partial class MainPage : BaseXamlPage
+ {
+ private partial void InitializeComponent();
+ public void InitializeComponentRuntime() { }
+ public MainPage() { InitializeComponent(); }
+ }
+ """;
+
+ var trees = new[]
+ {
+ CSharpSyntaxTree.ParseText(stub, path: "Stub.cs", encoding: System.Text.Encoding.UTF8),
+ CSharpSyntaxTree.ParseText(ic!, path: "IC.cs", encoding: System.Text.Encoding.UTF8),
+ CSharpSyntaxTree.ParseText(StripGeneratedCodeAttribute(uc!), path: "UC.cs", encoding: System.Text.Encoding.UTF8),
+ };
+ var comp = CreateMauiCompilation(trees);
+
+ // CS0108 (member hides inherited member) must NOT appear at ANY severity — the pragma in the
+ // generated UC file must suppress it. (Checked independently of general errors so the test
+ // would fail if the pragma were missing, regardless of warnings-as-errors configuration.)
+ var cs0108 = comp.GetDiagnostics().Where(d => d.Id == "CS0108").ToArray();
+ Assert.Empty(cs0108);
+
+ var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray();
+ Assert.True(errors.Length == 0,
+ $"Inherited-XAML generation should compile, but got:\n{string.Join("\n", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}");
+ }
+
+ ///
+ /// Diagnostics classification (Kirill Ovchinnikov's requirement), the "empty UpdateComponent" design.
+ /// UpdateComponent() is always present (member stability / no EnC churn), but its BODY is empty
+ /// when a generation carries no XAML change and non-empty (a patch) when the XAML changed. The runtime
+ /// classifies a delta as a XAML change — SYNCHRONOUSLY, pre-dispatch, on
+ /// HotReloadRequestedEventArgs.HandledTypes, exactly where XamlTools reads it today — by
+ /// inspecting whether UpdateComponent()'s body is non-empty. An empty UC (a page whose XAML did
+ /// not change, e.g. a pure C#/code-behind edit) is correctly NOT reported as a XAML change. No live
+ /// instance or main-thread dispatcher is needed: classification is a property of the type's UC body.
+ ///
+ [MetadataUpdateFact]
+ public void UpdateRequested_ReportsXamlChange_WhenUpdateComponentBodyIsNonEmpty()
+ {
+ XamlHotReloadState.Reset();
+
+ const string switchName = "Microsoft.Maui.RuntimeFeature.IsIncrementalHotReloadEnabled";
+ AppContext.TryGetSwitch(switchName, out var previousSwitch);
+ AppContext.SetSwitch(switchName, true);
+
+ string Page(string text) => $$"""
+
+
+
+
+
+
+ """;
+
+ var xamlV1 = Page("Hello");
+ var xamlV2 = Page("World");
+
+ var (icV1, ucV1, icV2, ucV2, _, _) = RunSourceGenAllPhases(xamlV1, xamlV2, xamlV2);
+ Assert.NotNull(ucV1); // v1: UpdateComponent() present but EMPTY (first generation, no XAML change)
+ Assert.NotNull(ucV2); // v2: UpdateComponent() carries a patch (non-empty)
+
+ var (peV1, pdbV1, compilationV1) = CompileSources(PageStub, icV1, StripGeneratedCodeAttribute(ucV1!));
+
+ var alc = new AssemblyLoadContext("E2EClassifyTest", isCollectible: true);
+ IReadOnlyList? lastHandled = null;
+ EventHandler capture = (_, e) => lastHandled = e.HandledTypes;
+ HotReloadDiagnostics.UpdateRequested += capture;
+ try
+ {
+ var assembly = alc.LoadFromStream(new MemoryStream(peV1), new MemoryStream(pdbV1));
+ var pageType = assembly.GetType(PageClass)!;
+
+ // The loaded type's UpdateComponent() is EMPTY → the update is NOT a XAML change.
+ XamlIncrementalHotReloadHandler.UpdateApplication(new[] { pageType });
+ Assert.NotNull(lastHandled);
+ Assert.DoesNotContain(pageType, lastHandled!);
+
+ // Apply the V1→V2 delta: UpdateComponent()'s body becomes non-empty. The same notification now
+ // classifies the type as a XAML change.
+ var compilationV2 = CreateMauiCompilation(new[]
+ {
+ CSharpSyntaxTree.ParseText(PageStub, path: "PageStub.cs", encoding: System.Text.Encoding.UTF8),
+ CSharpSyntaxTree.ParseText(icV2, path: "IC.cs", encoding: System.Text.Encoding.UTF8),
+ CSharpSyntaxTree.ParseText(StripGeneratedCodeAttribute(ucV2!), path: "UC.cs", encoding: System.Text.Encoding.UTF8),
+ });
+ AssertNoCompileErrors(compilationV2, "V2");
+
+ var baseline = EmitBaseline.CreateInitialBaseline(
+ compilationV1, ModuleMetadata.CreateFromImage(peV1),
+ debugInformationProvider: handle => default,
+ localSignatureProvider: handle => default,
+ hasPortableDebugInformation: true);
+
+ ApplyMethodBodyDelta(assembly, compilationV1, compilationV2, baseline);
+ XamlIncrementalHotReloadHandler.UpdateApplication(new[] { pageType });
+ Assert.NotNull(lastHandled);
+ Assert.Contains(pageType, lastHandled!);
+ }
+ finally
+ {
+ HotReloadDiagnostics.UpdateRequested -= capture;
+ alc.Unload();
+ AppContext.SetSwitch(switchName, previousSwitch);
+ }
+ }
+
+ static void AssertNoCompileErrors(CSharpCompilation comp, string label)
+ {
+ var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray();
+ if (errors.Length > 0)
+ Assert.Fail($"{label} compilation failed:\n{string.Join("\n", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}");
+ }
+
+ ///
+ /// Emits an EnC delta updating both InitializeComponent and UpdateComponent from
+ /// to , applies it to the live
+ /// , and returns the updated baseline for the next delta. Every edit is
+ /// an UPDATE (never Insert/Delete) because UpdateComponent() exists from the first generation.
+ ///
+ static EmitBaseline ApplyMethodBodyDelta(
+ Assembly assembly, CSharpCompilation oldComp, CSharpCompilation newComp, EmitBaseline baseline)
+ {
+ var oldType = oldComp.GetTypeByMetadataName(PageClass)!;
+ var newType = newComp.GetTypeByMetadataName(PageClass)!;
+
+ var oldICDef = oldType.GetMembers("InitializeComponent").OfType().First();
+ var newICDef = newType.GetMembers("InitializeComponent").OfType().First();
+
+ var edits = new List
+ {
+ new SemanticEdit(SemanticEditKind.Update,
+ oldICDef.PartialImplementationPart ?? oldICDef,
+ newICDef.PartialImplementationPart ?? newICDef),
+ new SemanticEdit(SemanticEditKind.Update,
+ oldType.GetMembers("UpdateComponent").Single(),
+ newType.GetMembers("UpdateComponent").Single()),
+ };
+
+ using var mdDelta = new MemoryStream();
+ using var ilDelta = new MemoryStream();
+ using var pdbDelta = new MemoryStream();
+ var result = newComp.EmitDifference(
+ baseline, edits,
+ isAddedSymbol: _ => false,
+ mdDelta, ilDelta, pdbDelta,
+ System.Threading.CancellationToken.None);
+ Assert.True(result.Success, $"EmitDifference failed:\n{string.Join("\n", result.Diagnostics)}");
+
+ MetadataUpdater.ApplyUpdate(assembly, mdDelta.ToArray(), ilDelta.ToArray(), pdbDelta.ToArray());
+ return result.Baseline!;
+ }
+
+ ///
+ /// The crown-jewel runtime proof of reverse version transitions (Kirill's requirement): a live
+ /// instance created at V1 (Text="Hello"), hot-reloaded forward to V2 (Text="World"), then
+ /// hot-reloaded BACKWARD to V1 (Text="Hello") — the live object's property must return to the
+ /// baseline value. Because UpdateComponent() exists from the first generation, every transition is
+ /// a method-body UPDATE (no member churn), and because patches are absolute the reverse patch
+ /// deterministically restores the earlier value.
+ ///
+ [MetadataUpdateFact]
+ public void PropertyRevert_AppliedViaHotReload_ReturnsToBaseline()
+ {
+ XamlHotReloadState.Reset();
+
+ string Page(string text) => $$"""
+
+
+
+
+
+
+ """;
+
+ var xamlV1 = Page("Hello");
+ var xamlV2 = Page("World");
+ var xamlV3 = Page("Hello"); // revert — byte-identical to V1
+
+ var (icV1, ucV1, icV2, ucV2, icV3, ucV3) = RunSourceGenAllPhases(xamlV1, xamlV2, xamlV3);
+ Assert.NotNull(ucV1); // always-emit: UpdateComponent() is present from the first generation
+ Assert.NotNull(ucV2);
+ Assert.NotNull(ucV3);
+
+ // Baseline V1 = IC + (empty) UC. Compiling UC into the baseline means every transition below
+ // is an UPDATE of UpdateComponent(), never an Insert — the member never churns.
+ var (peV1, pdbV1, compilationV1) = CompileSources(PageStub, icV1, StripGeneratedCodeAttribute(ucV1!));
+
+ var alc = new AssemblyLoadContext("E2ERevertTest", isCollectible: true);
+ try
+ {
+ var assembly = alc.LoadFromStream(new MemoryStream(peV1), new MemoryStream(pdbV1));
+ var pageType = assembly.GetType(PageClass)!;
+ var instance = Activator.CreateInstance(pageType)!;
+ var page = (ContentPage)instance;
+
+ string CurrentText() => ((Layout)page.Content!).Children.OfType