Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
<_MauiXamlInflator Condition="' $(MauiXamlInflator)' != '' ">$(MauiXamlInflator)</_MauiXamlInflator>
<_MauiXamlInflator Condition=" '$(MauiXamlInflator)' == '' ">SourceGen</_MauiXamlInflator>

<!-- XAML Incremental Hot Reload (XIHR): off by default. Hot reload is a dev-time feature; when
enabled it emits per-page registry calls and keeps the runtime feature switch on. Set
<EnableMauiIncrementalHotReload>true</...> to opt in (legacy XAML Hot Reload remains the
default fallback). Resolves to an explicit true/false so the value always flows to the source
generator and runtime config. -->
<!-- XAML Incremental Hot Reload (XIHR): on by default for .NET 11 projects (Preview 7). Hot reload is a
dev-time feature; when enabled it emits per-page registry calls and keeps the runtime feature
switch on, so it defaults on for Debug builds only and stays off for Release/publish, where the
registry calls and runtime switch trim away. Set <EnableMauiIncrementalHotReload>false</...> to
opt out (legacy XAML Hot Reload remains available as a fallback). Resolves to an explicit
true/false so the value always flows to the source generator and runtime config. -->
<EnableMauiIncrementalHotReload Condition="'$(EnableMauiIncrementalHotReload)' == '' and '$(Configuration)' == 'Debug'">true</EnableMauiIncrementalHotReload>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Config Impact / Regression — This line is an undisclosed revert of merged PR #36832, bundled into a PR described as source-generator-only.

git log on this file confirms HEAD contains the commit b81c6c064c Revert "Turn XAML Incremental Hot Reload (XIHR) off by default (#36832)". #36832 was merged into net11.0 on 2026-07-28 with the explicit rationale: "XIHR was enabled by default for Debug builds on the net11.0 branch, which is causing issues. This PR makes XIHR opt-in instead of on-by-default." This line re-flips that default back on for every Debug build.

The PR description states "This is a source-generator-only change (src/Controls/src/SourceGen); no public API changes" — which isn't accurate for this file. There is no mention of the revert, no link to #36832, and no statement that the issues motivating it are resolved.

Scenario: every net11.0 MAUI app that builds Debug without explicitly setting EnableMauiIncrementalHotReload=false silently re-acquires whatever #36832 was disabling. Because the flip is buried in a PR titled "deterministic versioning," bisecting a resulting regression points at the wrong change. This also compounds the two ❌ findings below, both of which are only reachable by default because of this line.

Two things worth noting: the repo's own review rules require that a PR reverting a labeled fix name that PR and get explicit author acknowledgment; and the test comment added at MSBuildTests.cs:311-313 already says "on by default in Debug" — so the flip is known to the change, just not to the description.

Suggested fix: split the default flip out of this PR and land the determinism/always-emit work first; or keep it and explicitly document the revert, link #36832, confirm its original issues are fixed, and get sign-off from whoever merged it.

Flagged by: 3/3 reviewers

<EnableMauiIncrementalHotReload Condition="'$(EnableMauiIncrementalHotReload)' == ''">false</EnableMauiIncrementalHotReload>

<!-- XAML Hot Reload mode for IDE communication (Legacy or SourceGen) -->
Expand Down
29 changes: 29 additions & 0 deletions src/Controls/src/SourceGen/GeneratorHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,35 @@ public static string EscapeIdentifier(string identifier)
: $"@{identifier}";
}

/// <summary>
/// A stable, deterministic 32-bit content hash (FNV-1a) of the XAML text, used as the
/// <c>__version</c> 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 <see cref="string.GetHashCode()"/> it is not randomized per
/// process, so it is reproducible across builds/hosts. Returned as a non-negative int.
/// </summary>
public static int StableContentHash(string? content)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think xxHash128 would be better algorithm for this purpose - faster and less likely to produce collisions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can probably use the System.IO.Hashing NuGet for this, works even on .NET framework. We use it on dotnet/android.

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.

Thanks @tmat. Rather than switching FNV-1a → xxHash128, the follow-up #36912 removes StableContentHash (and the __version field it fed) entirely: __version turned out to be dead — nothing reads it at runtime and Hot Reload dispatch is unconditional — so the content hash was unnecessary. The code you flagged no longer exists as of #36912.

{
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);
}
}
Comment on lines +45 to +66

public static ProjectItem? ComputeProjectItem((AdditionalText additionalText, AnalyzerConfigOptionsProvider optionsProvider) tuple, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
Expand Down
30 changes: 17 additions & 13 deletions src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);");
}
}
Expand Down Expand Up @@ -276,8 +280,9 @@ public static bool TryGetRootType(
}

/// <summary>
/// Generates a single patch body (the code for an <c>if (__version == fromVersion) { ... }</c> block)
/// from two XAML versions. Returns <see langword="null"/> 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 <see langword="null"/> when the diff is
/// structural, empty, or on parse error.
/// </summary>
/// <param name="cachedOldRoot">The cached parsed tree from the previous generation (may be null on first diff).</param>
/// <summary>
Expand All @@ -297,10 +302,9 @@ public static bool TryGetRootType(
/// </param>
/// <param name="emptyDiff">
/// Set to <see langword="true"/> 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 <c>if (__version == 0)</c>. 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.
/// </param>
public static string? TryGeneratePatchBody(
SGRootNode? cachedOldRoot,
Expand Down
88 changes: 52 additions & 36 deletions src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,28 @@
namespace Microsoft.Maui.Controls.SourceGen;

/// <summary>
/// Generates a single <c>UpdateComponent()</c> partial method containing accumulated
/// <c>if (__version == N)</c> patch blocks from successive XAML Hot Reload edits.
/// Generates a single <c>UpdateComponent()</c> partial method that applies one absolute
/// previous→current patch for XAML Incremental Hot Reload.
/// </summary>
/// <remarks>
/// <para>
/// Per the spec, the generated method chains sequential <c>if</c> blocks (not <c>else if</c>):
/// The method is emitted UNCONDITIONALLY (no <c>if (__version == N)</c> 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 <c>__version</c> with a deterministic
/// content hash of the current XAML:
/// <code>
/// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

💡 Documentation — This example documents behavior the shipped generator does not emit, and it contradicts the classifier that depends on it.

GenerateUpdateComponent (lines 194-201) writes only the patch body — no __version assignment and no return;. Only InitializeComponent stamps __version now, so both line 31 and the "then stamps __version" sentence at lines 25-26 describe removed behavior. Relatedly, InitializeComponentCodeWriter.cs:100 calls __version a marker "read by diagnostics/tooling," but nothing reads the field anywhere in the repo — it is genuinely write-only.

This isn't cosmetic. The empty-UC IL-length classifier only works because an empty body is truly { } (1-4 IL bytes). A maintainer trusting this example would believe even an "empty" UpdateComponent() contains an ldc.i4/stfld pair plus ret — which would exceed the EmptyUpdateComponentMaxIL = 8 threshold and appear to defeat the classifier. The doc actively misleads about the mechanism it sits next to.

Suggested fix: drop the __version = ...; and return; lines from the code example and the "stamps __version" claim, and correct the __version comment in InitializeComponentCodeWriter.cs to say it is a write-only stamp with no current reader.

Flagged by: 2/3 reviewers

/// return;
/// }
/// </code>
/// A v0 instance chains through ALL patches; a fresh instance (whose <c>InitializeComponent</c>
/// sets <c>__version</c> 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.
/// </para>
/// <para>
/// Property value encoding strategy (in priority order):
Expand All @@ -48,9 +55,14 @@ static class UpdateComponentCodeWriter
const string NewLine = "\n";

/// <summary>
/// Generates the code for a single <c>if (__version == fromVersion) { ... __version = toVersion; }</c> block.
/// Returns <see langword="null"/> when <paramref name="diff"/> contains no changes.
/// Generates the statements that apply a single previous→current patch (property sets, child-list
/// changes, etc.), WITHOUT any <c>if (__version == N)</c> guard or <c>__version</c> assignment — the
/// caller (<see cref="GenerateUpdateComponent(INamedTypeSymbol, string, string?, int)"/>) wraps this
/// body and stamps the content-hash identity. Returns <see langword="null"/> when
/// <paramref name="diff"/> contains no changes.
/// </summary>
/// <param name="fromVersion">Vestigial: the monotonic version no longer drives dispatch (kept for the state/bookkeeping call chain and test signatures).</param>
/// <param name="toVersion">Vestigial: see <paramref name="fromVersion"/>.</param>
/// <param name="newIds">ID dictionary for added nodes (from the new tree). May be null for tests.</param>
public static string? GeneratePatchBody(
XamlTreeDiff diff,
Expand Down Expand Up @@ -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();
}
Expand All @@ -143,20 +150,31 @@ static class UpdateComponentCodeWriter
/// Assembles a complete <c>UpdateComponent()</c> source file from accumulated patch bodies.
/// Each patch body becomes an <c>if (__version == N) { ... }</c> block inside the single method.
/// </summary>
public static string? GenerateUpdateComponent(
/// <summary>
/// Generates the <c>UpdateComponent()</c> method body from a single baseline→current patch.
/// The patch is <em>always</em> emitted (even when <paramref name="patchBody"/> 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.
/// </summary>
public static string GenerateUpdateComponent(
INamedTypeSymbol rootType,
string accessModifier,
List<string> 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();
Expand All @@ -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!);
}
}

Expand All @@ -207,7 +221,7 @@ static class UpdateComponentCodeWriter
if (patchBody == null)
return null;

return GenerateUpdateComponent(rootType, accessModifier, new List<string> { patchBody }, startVersion: fromVersion);
return GenerateUpdateComponent(rootType, accessModifier, patchBody);
}

static void WriteIndentedBody(IndentedTextWriter codeWriter, string body)
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1163,7 +1178,8 @@ static void TryEmitAttachedPropertyChange(
break;
}
}
if (getter != null) break;
if (getter != null)
break;
current = current.BaseType!;
}

Expand Down
Loading
Loading