Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
092d3d6
Add reusable XAML hot reload test harness
Redth Jul 22, 2026
dc688ab
Extend hot reload diagnostic harness
Redth Jul 22, 2026
dac0e4a
Add buffered diagnostics for XAML source generation
Redth Jul 22, 2026
08f635e
Fix complex property incremental hot reload generation
Redth Jul 22, 2026
832772d
Add issue-derived XAML hot reload recovery coverage
Redth Jul 22, 2026
05de170
Add hot reload harness capabilities
Redth Jul 22, 2026
1f13222
Add cross-assembly hot reload generator tests
Redth Jul 22, 2026
329857b
Add Wave-2 Phase-1 resource/theme hot reload tests (RT-01..RT-07)
Redth Jul 22, 2026
bec21eb
Add visual state hot reload behavior probes
Redth Jul 22, 2026
8d89c45
[SourceGen.UnitTests] Wave-2 template/selector hot-reload tests (TS-0…
Redth Jul 22, 2026
3f6d844
Add Wave-2 nested/custom-control hot reload tests (NC-01..NC-03)
Redth Jul 22, 2026
e40145d
Promote NC-03 nested hot reload test to GREEN
Redth Jul 22, 2026
edcdf72
Add binding and markup hot reload coverage
Redth Jul 22, 2026
d11f035
Add Wave-2 Phase-2 resource/theme hot reload tests (RT-08..RT-11)
Redth Jul 22, 2026
dc7fc1c
Add combined VSM theme resource hot reload coverage
Redth Jul 22, 2026
538e2b2
Add nested generated root hot reload probe
Redth Jul 22, 2026
2884e2b
Add generated root resource decline guard
Redth Jul 22, 2026
c08d910
Fix nested hot reload test stubs
Redth Jul 22, 2026
daaa11f
Document Wave-2 XAML hot reload coverage
Redth Jul 22, 2026
19ee924
Split AI-assisted hot reload tests by behavior
Redth Jul 22, 2026
b5a15c0
Strengthen hot reload guard coverage
Redth Jul 22, 2026
523ee85
Gate metadata update theories when unsupported
Redth Jul 22, 2026
390e6f5
Make source generator diagnostic reporting explicit
Redth Jul 27, 2026
9d87613
Keep AI-assisted hot reload coverage passing-only
Redth Jul 27, 2026
c66fc3d
Harden hot reload test metadata requirements
Redth Jul 27, 2026
d72ca64
Merge net11.0 into redth-hot-reload-test-harness
Copilot Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ PrePost newblock() =>
var methodName = genSwitch ? "InitializeComponentSourceGen" : "InitializeComponent";
codeWriter.WriteLine($"private partial void {methodName}()");
root!.XmlType.TryResolveTypeSymbol(null, compilation, xmlnsCache, typeCache, out var baseType);
var sgcontext = new SourceGenContext(codeWriter, compilation, sourceProductionContext, xmlnsCache, typeCache, rootType!, baseType, xamlItem.ProjectItem);
var sgcontext = new SourceGenContext(codeWriter, compilation, sourceProductionContext, xmlnsCache, typeCache, rootType!, baseType, xamlItem.ProjectItem, sourceProductionContext.ReportDiagnostic);

// Compute stable node IDs before Visit() mutates the tree (markup expansion etc.)
// Use cached effective IDs (from state) if available, to stay consistent with UC patches.
Expand Down
3 changes: 2 additions & 1 deletion src/Controls/src/SourceGen/SetPropertyHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ public static void AddLazyResourceToResourceDictionary(IndentedTextWriter writer
context.TypeCache,
context.RootType,
null,
context.ProjectItem)
context.ProjectItem,
context.ReportDiagnostic)
{
ParentContext = context
};
Expand Down
52 changes: 48 additions & 4 deletions src/Controls/src/SourceGen/SourceGenContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@

namespace Microsoft.Maui.Controls.SourceGen;

class SourceGenContext(IndentedTextWriter writer, Compilation compilation, SourceProductionContext sourceProductionContext, AssemblyAttributes assemblyCaches, IDictionary<XmlType, INamedTypeSymbol> typeCache, ITypeSymbol rootType, ITypeSymbol? baseType, ProjectItem projectItem)
class SourceGenContext(IndentedTextWriter writer, Compilation compilation, SourceProductionContext sourceProductionContext, AssemblyAttributes assemblyCaches, IDictionary<XmlType, INamedTypeSymbol> typeCache, ITypeSymbol rootType, ITypeSymbol? baseType, ProjectItem projectItem, Action<Diagnostic> diagnosticReporter)
{
internal static SourceGenContext CreateNewForTests() => new SourceGenContext(
static readonly Action<Diagnostic> s_noOpDiagnosticReporter = static _ => { };
List<Diagnostic>? _bufferedDiagnostics;

internal static SourceGenContext CreateNewForTests(Action<Diagnostic>? diagnosticReporter = null) => new SourceGenContext(
null!,
null!,
default,
null!,
new Dictionary<XmlType, INamedTypeSymbol>(),
null!,
null,
null!);
null!,
diagnosticReporter ?? s_noOpDiagnosticReporter);

public SourceProductionContext SourceProductionContext => sourceProductionContext;
public IndentedTextWriter Writer => writer;
Expand All @@ -34,6 +38,12 @@ class SourceGenContext(IndentedTextWriter writer, Compilation compilation, Sourc
public IDictionary<INode, ILocalValue> Variables { get; } = new Dictionary<INode, ILocalValue>();
public void ReportDiagnostic(Diagnostic diagnostic)
{
if (ParentContext is not null)
{
ParentContext.ReportDiagnostic(diagnostic);
return;
}

// Check if this diagnostic should be suppressed based on NoWarn
var noWarn = ProjectItem?.NoWarn;
if (!string.IsNullOrEmpty(noWarn))
Expand All @@ -51,8 +61,42 @@ public void ReportDiagnostic(Diagnostic diagnostic)
}
}
}
sourceProductionContext.ReportDiagnostic(diagnostic);

if (_bufferedDiagnostics is not null)
{
_bufferedDiagnostics.Add(diagnostic);
return;
}

ReportDiagnosticCore(diagnostic);
}

internal int BufferedDiagnosticCount => _bufferedDiagnostics?.Count ?? 0;

internal void BeginDiagnosticBuffering()
{
if (_bufferedDiagnostics is not null)
throw new InvalidOperationException("Diagnostic buffering is already active.");

_bufferedDiagnostics = [];
}

internal void FlushBufferedDiagnostics()
{
if (_bufferedDiagnostics is null)
throw new InvalidOperationException("Diagnostic buffering is not active.");

var diagnostics = _bufferedDiagnostics;
_bufferedDiagnostics = null;
foreach (var diagnostic in diagnostics)
ReportDiagnosticCore(diagnostic);
}

internal void DiscardBufferedDiagnostics() => _bufferedDiagnostics = null;

void ReportDiagnosticCore(Diagnostic diagnostic)
=> diagnosticReporter(diagnostic);

public IDictionary<INode, ILocalValue> ServiceProviders { get; } = new Dictionary<INode, ILocalValue>();
public IDictionary<INode, (ILocalValue namescope, IDictionary<string, ILocalValue> namesInScope)> Scopes = new Dictionary<INode, (ILocalValue, IDictionary<string, ILocalValue>)>();
public SourceGenContext? ParentContext { get; set; }
Expand Down
233 changes: 227 additions & 6 deletions src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,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 @@ -567,9 +568,218 @@ static void EmitNewElementProperties(
TryEmitMarkupNodeChange(codeWriter, syntheticDiff, typeSymbol, varName, isRoot: false,
compilation, xmlnsCache, typeCache, rootType, sourceProductionContext, projectItem);
}
else if (kvp.Value is ElementNode or ListNode)
{
if (!TryEmitNewElementComplexProperty(
codeWriter,
element,
kvp.Value,
varName,
typeSymbol,
compilation,
xmlnsCache,
typeCache,
rootType,
sourceProductionContext,
projectItem))
{
codeWriter.WriteLine($"// Complex property '{kvp.Key.LocalName}' ({kvp.Value.GetType().Name}) — skipped (not yet supported)");
}
}
}
}

/// <summary>
/// Emits an element-valued property while constructing a new element. This deliberately uses
/// the InitializeComponent visitor pipeline; mutations on existing elements continue through
/// <see cref="EmitPropertyChange"/> and retain their narrower supported surface.
/// </summary>
static bool TryEmitNewElementComplexProperty(
IndentedTextWriter codeWriter,
ElementNode element,
INode propertyNode,
string varName,
INamedTypeSymbol typeSymbol,
Compilation compilation,
AssemblyAttributes xmlnsCache,
IDictionary<XmlType, INamedTypeSymbol> typeCache,
INamedTypeSymbol rootType,
SourceProductionContext sourceProductionContext,
ProjectItem? projectItem)
{
// This speculative path does not run SetResourcesVisitor, so accepting resources here
// would emit an incomplete subtree.
if (ContainsInlineResources(propertyNode))
return false;

var preflightDiagnosticContext = CreateConversionContext(
compilation,
sourceProductionContext,
xmlnsCache,
typeCache,
rootType,
projectItem);
preflightDiagnosticContext.BeginDiagnosticBuffering();
bool containsStaticResource;
try
{
containsStaticResource = ContainsStaticResourceReference(propertyNode, markup => ExpandMarkupForUC(
markup,
compilation,
xmlnsCache,
typeCache,
rootType,
sourceProductionContext,
projectItem,
preflightDiagnosticContext.ReportDiagnostic));
}
finally
{
preflightDiagnosticContext.DiscardBufferedDiagnostics();
}

if (containsStaticResource)
return false;

using var captureStringWriter = new StringWriter(CultureInfo.InvariantCulture);
using var captureWriter = new IndentedTextWriter(captureStringWriter, "\t") { NewLine = NewLine };
var context = CreateConversionContext(
compilation,
sourceProductionContext,
xmlnsCache,
typeCache,
rootType,
projectItem,
captureWriter);
context.Variables[element] = new DirectValue(typeSymbol, varName);
context.BeginDiagnosticBuffering();

try
{
propertyNode.Accept(new CreateValuesVisitor(context), element);
propertyNode.Accept(new SetPropertiesVisitor(context, stopOnResourceDictionary: true), element);
}
catch (InvalidOperationException)
{
context.DiscardBufferedDiagnostics();
return false;
}
Comment on lines +676 to +680

captureWriter.Flush();
var capturedCode = captureStringWriter.ToString();
if (string.IsNullOrWhiteSpace(capturedCode))
{
context.DiscardBufferedDiagnostics();
return false;
}

codeWriter.WriteLine("{");
codeWriter.Indent++;
foreach (var line in capturedCode.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None))
{
if (!string.IsNullOrEmpty(line))
codeWriter.WriteLine(line);
}

foreach (var localMethod in context.LocalMethods)
{
codeWriter.WriteLine();
foreach (var line in localMethod.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None))
{
if (string.IsNullOrWhiteSpace(line))
codeWriter.InnerWriter.WriteLine();
else
codeWriter.WriteLine(line);
}
}

codeWriter.Indent--;
codeWriter.WriteLine("}");
context.FlushBufferedDiagnostics();
return true;
}

static bool ContainsInlineResources(INode node)
{
if (node is ElementNode element)
{
if (element.XmlType.Name == "ResourceDictionary")
return true;

foreach (var property in element.Properties)
{
if (property.Key.LocalName == "Resources"
|| property.Key.LocalName.EndsWith(".Resources", StringComparison.Ordinal)
|| ContainsInlineResources(property.Value))
{
return true;
}
}

foreach (var child in element.CollectionItems)
{
if (ContainsInlineResources(child))
return true;
}

return false;
}

if (node is ListNode list)
{
foreach (var child in list.CollectionItems)
{
if (ContainsInlineResources(child))
return true;
}
}

return false;
}

static bool ContainsStaticResourceReference(INode node, Func<MarkupNode, INode?> expandMarkup)
{
if (node is MarkupNode markup)
{
var expanded = expandMarkup(markup);
return expanded is not null && ContainsStaticResourceReference(expanded, expandMarkup);
}

if (node is ElementNode element)
{
if (IsStaticResourceExtension(element.XmlType.Name))
return true;

foreach (var property in element.Properties.Values)
{
if (ContainsStaticResourceReference(property, expandMarkup))
return true;
}

foreach (var child in element.CollectionItems)
{
if (ContainsStaticResourceReference(child, expandMarkup))
return true;
}

return false;
}

if (node is ListNode list)
{
foreach (var child in list.CollectionItems)
{
if (ContainsStaticResourceReference(child, expandMarkup))
return true;
}
}

return false;
}

static bool IsStaticResourceExtension(string typeName) =>
typeName is "StaticResource" or "StaticResourceExtension";

/// <summary>
/// Recursively creates children for a newly created element, handling both
/// Layout containers (Children.Add) and Content containers (Content property).
Expand Down Expand Up @@ -1163,7 +1373,8 @@ static void TryEmitAttachedPropertyChange(
break;
}
}
if (getter != null) break;
if (getter != null)
break;
current = current.BaseType!;
}

Expand Down Expand Up @@ -1264,12 +1475,20 @@ static bool TryEmitMarkupNodeChange(
IDictionary<XmlType, INamedTypeSymbol> typeCache,
INamedTypeSymbol rootType,
SourceProductionContext sourceProductionContext,
ProjectItem? projectItem)
ProjectItem? projectItem,
Action<Diagnostic>? diagnosticReporter = null)
{
var markupString = markupNode.MarkupString;

// Build a minimal SourceGenContext for ExpandMarkupsVisitor's parser
var ctx = CreateConversionContext(compilation, sourceProductionContext, xmlnsCache, typeCache, rootType, projectItem);
var ctx = CreateConversionContext(
compilation,
sourceProductionContext,
xmlnsCache,
typeCache,
rootType,
projectItem,
diagnosticReporter: diagnosticReporter);

// Classification: expression or markup extension?
bool TryResolveMarkup(string name)
Expand Down Expand Up @@ -1759,12 +1978,14 @@ static SourceGenContext CreateConversionContext(
IDictionary<XmlType, INamedTypeSymbol> typeCache,
INamedTypeSymbol rootType,
ProjectItem? projectItem,
IndentedTextWriter? writer = null)
IndentedTextWriter? writer = null,
Action<Diagnostic>? diagnosticReporter = null)
{
var pi = projectItem ?? new ProjectItem(EmptyAdditionalText.Instance, EmptyConfigOptions.Instance);
diagnosticReporter ??= sourceProductionContext.ReportDiagnostic;
return new SourceGenContext(
writer ?? new IndentedTextWriter(new StringWriter()),
compilation, sourceProductionContext,
xmlnsCache, typeCache, rootType, rootType.BaseType, pi);
xmlnsCache, typeCache, rootType, rootType.BaseType, pi, diagnosticReporter);
}
}
4 changes: 2 additions & 2 deletions src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ public void Visit(ElementNode node, INode parentNode)
Writer.WriteLine($"object {methodName}()");
using (PrePost.NewBlock(Writer, begin: "{", end: "}"))
{
var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem)
var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem, context.ReportDiagnostic)
{
ParentContext = context,
};
Expand All @@ -298,7 +298,7 @@ public void Visit(ElementNode node, INode parentNode)
Writer.WriteLine($"{variable.ValueAccessor}.LoadTemplate = () =>");
using (PrePost.NewBlock(Writer, begin: "{", end: "};"))
{
var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem)
var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem, context.ReportDiagnostic)
{
ParentContext = context,
};
Expand Down
Loading
Loading