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 - + + true false 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("