` block:
+
+```markdown
+## π§ͺ PR Test Evaluation
+
+**Overall Verdict:** [β
Tests are adequate | β οΈ Tests need improvement | β Tests are insufficient]
+
+[1-2 sentence summary]
+
+> π / π β Was this evaluation helpful? React to let us know!
+
+
+π Expand Full Evaluation
+
+[Full report from SKILL.md]
+
+
+```
diff --git a/.github/workflows/merge-net11-to-release.yml b/.github/workflows/merge-net11-to-release.yml
new file mode 100644
index 000000000000..ac13e47f9372
--- /dev/null
+++ b/.github/workflows/merge-net11-to-release.yml
@@ -0,0 +1,22 @@
+# Merge net11.0 β next release branch
+# Target branch is configured in /github-merge-flow-release-11.jsonc (MergeToBranch)
+
+name: Merge net11.0 to next release
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - net11.0
+ schedule:
+ - cron: '0 4 * * *'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ Merge:
+ uses: dotnet/arcade/.github/workflows/inter-branch-merge-base.yml@main
+ with:
+ configuration_file_path: 'github-merge-flow-release-11.jsonc'
diff --git a/docs/design/cli.md b/docs/design/cli.md
index c61fbb07a61f..ec396c2dba94 100644
--- a/docs/design/cli.md
+++ b/docs/design/cli.md
@@ -1,7 +1,7 @@
---
description: "Design document for the maui CLI tool"
date: 2026-01-07
-updated: 2026-02-26
+updated: 2026-02-27
---
# `maui` CLI Design Document
@@ -197,6 +197,170 @@ All commands use consistent exit codes:
| 4 | Network error (download failed) |
| 5 | Resource not found |
+## Device Discovery
+
+### `maui device list`
+
+Lists connected devices, running emulators, and available simulators
+across all platforms from a single command.
+
+**Usage:**
+
+```bash
+maui device list [--platform ] [--json]
+```
+
+**Options:**
+
+- `--platform `: Filter by platform (`android`, `ios`,
+ `maccatalyst`). If omitted, lists all platforms.
+- `--json`: Structured JSON output for machine consumption.
+
+**Human-readable output:**
+
+```
+ID Description Type Platform Status
+emulator-5554 Pixel 7 - API 35 Emulator android Online
+0A041FDD400327 Pixel 7 Pro Device android Online
+94E71AE5-8040-4DB2-8A9C-6CD24EF4E7DE iPhone 16 - iOS 26.0 Simulator ios Shutdown
+FBF5DCE8-EE2B-4215-8118-3A2190DE1AD7 iPhone 14 - iOS 26.0 Simulator ios Booted
+AF40CC64-2CDB-5F16-9651-86BCDF380881 My iPhone 15 Device ios Paired
+```
+
+**JSON output (`--json`):**
+
+```json
+{
+ "devices": [
+ {
+ "id": "emulator-5554",
+ "description": "Pixel 7 - API 35",
+ "type": "Emulator",
+ "platform": "android",
+ "status": "Online"
+ },
+ {
+ "id": "FBF5DCE8-EE2B-4215-8118-3A2190DE1AD7",
+ "description": "iPhone 14 - iOS 26.0",
+ "type": "Simulator",
+ "platform": "ios",
+ "status": "Booted"
+ }
+ ]
+}
+```
+
+The `id` field is the same identifier accepted by `dotnet run --device
+`, so output from `maui device list` can be piped directly into a
+run command.
+
+### Two Approaches to Device Enumeration
+
+There are two ways to enumerate devices, each suited to different
+scenarios.
+
+#### Approach A: Via `dotnet run --list-devices` (project-based)
+
+The .NET SDK (β₯ .NET 11) provides `dotnet run --list-devices`, which
+calls the [`ComputeAvailableDevices`][compute-android] MSBuild target
+defined by each platform workload ([spec][dotnet-run-spec]):
+
+- **Android** ([dotnet/android]): calls `adb devices`, returns
+ serial, description, type (Device/Emulator), status, model
+- **Apple** ([dotnet/macios]): calls `simctl list` and `devicectl
+ list`, returns UDID, description, type (Device/Simulator),
+ OS version, RuntimeIdentifier
+
+This approach **requires a project file** β MSBuild evaluates the
+`.csproj` to locate the correct workload targets. It also operates
+**per-framework**: you select a target framework first, then get
+devices for that platform only.
+
+[dotnet/android]: https://github.com/dotnet/android
+[dotnet/macios]: https://github.com/dotnet/macios
+
+#### Approach B: Direct native tool invocation (project-free)
+
+The `maui` CLI calls the same native tools directly β `adb devices`,
+`xcrun simctl list devices`, `xcrun devicectl list devices` β without
+evaluating any MSBuild project. This returns a unified, cross-platform
+device list in a single call.
+
+#### Comparison
+
+| | Approach A (MSBuild) | Approach B (Native CLI) |
+|---|---|---|
+| **Project required** | Yes β needs `.csproj` | No |
+| **Cross-platform** | One platform per call (per TFM) | All platforms in one call |
+| **Metadata** | Rich (RuntimeIdentifier, workload-specific fields) | Standard (id, description, type, status) |
+| **Speed** | Slower (MSBuild evaluation + restore) | Fast (<2s, direct process calls) |
+| **ID compatibility** | Source of truth for `dotnet run --device` | Same native IDs β compatible |
+| **Requires workloads** | Yes (platform workload must be installed) | Only native tools (`adb`, `simctl`) |
+| **Extensible** | Workloads add new device types automatically | Must add support per platform |
+
+#### Scenarios Without a Project
+
+Several real workflows need device enumeration **before** a project
+exists or **outside** any project context:
+
+1. **AI agent bootstrapping** β An agent starting a "vibe coding"
+ session needs to discover available targets before scaffolding a
+ project. It cannot call `dotnet run --list-devices` because there
+ is no `.csproj` yet.
+
+2. **IDE startup** β VS Code opens a workspace with no MAUI project
+ loaded. The extension needs to populate its device picker to show
+ the user what's available. A project-free query is the only option.
+
+3. **Environment validation** β A developer runs `maui device list`
+ to answer "can I see my phone?" without needing to be inside any
+ project directory. This is a diagnostic step, not a build step.
+
+4. **CI pipeline setup** β A CI script checks that the expected
+ emulator or simulator is running before invoking `dotnet run`.
+ The check should not depend on a specific project file.
+
+5. **Multi-project solutions** β A solution contains both Android and
+ iOS projects. The developer wants a single unified device list
+ rather than running `--list-devices` per project.
+
+6. **Cross-platform overview** β `dotnet run --list-devices` shows
+ devices for one TFM at a time. A developer switching between
+ Android and iOS wants to see everything at once.
+
+#### Recommended Approach
+
+`maui device list` uses **Approach B** (direct native tool invocation)
+as its primary implementation:
+
+- It works anywhere β no project, no workload targets, no MSBuild
+ evaluation overhead.
+- Device identifiers are the same native IDs used by
+ `ComputeAvailableDevices`, so they are fully compatible with
+ `dotnet run --device`.
+- The `maui` CLI already wraps these native tools for other commands
+ (environment setup, emulator management), so device listing is a
+ natural extension.
+
+When a project **is** available and the user wants framework-specific
+device filtering, `dotnet run --list-devices` remains the right tool β
+it provides richer metadata (RuntimeIdentifier) and benefits from
+workload-specific logic. The two approaches are complementary:
+
+```
+maui device list β "What devices exist on this machine?"
+dotnet run --list-devices β "What devices can run this project?"
+```
+
+**Platform Implementation:**
+
+| Platform | Native tool | What is enumerated |
+|----------|------------|-------------------|
+| Android | `adb devices -l` | Physical devices and running emulators |
+| iOS (simulators) | `xcrun simctl list devices --json` | All simulators (booted + shutdown) |
+| iOS (physical) | `xcrun devicectl list devices` | Connected physical devices |
+| Mac Catalyst | (host machine) | The Mac itself |
+
## App Inspection Commands (Future)
> **Note**: App inspection commands are planned for a future release. The initial release focuses on environment setup and device management.
@@ -268,7 +432,6 @@ Initial implementation targets Android and iOS/Mac Catalyst, with Windows and ma
### Future Commands
-- `maui device list` for unified device/emulator/simulator listing across platforms
- `maui screenshot` for capturing screenshots of running apps
- `maui logs` for streaming device logs
- `maui tree` for inspecting the visual tree
@@ -292,6 +455,9 @@ maui tree --json # future
### AI Agent Workflow
```bash
+# 0. Discover available devices (no project needed)
+maui device list --json
+
# 1. Make code changes
# ... agent modifies MainPage.xaml ...
@@ -392,7 +558,7 @@ Visual Studio consumes the `Xamarin.Android.Tools.AndroidSdk` NuGet package from
|----------|------------|--------------|
| Workspace open | `maui apple check --json`, `maui android jdk check --json` | Show environment status in status bar / problems panel |
| Environment fix | `maui android install --json` | Display progress bar, stream `type: "progress"` messages |
-| Device picker | `maui device list --json` (future) | Populate device dropdown / selection UI |
+| Device picker | `maui device list --json` | Populate device dropdown / selection UI |
| Emulator launch | `maui android emulator start --json` | Show notification, update device list on completion |
### Benefits
@@ -440,12 +606,11 @@ simulators) are now included above. These were inspired by:
Future commands:
-- `maui device list` for unified device listing
- `maui logs` for viewing console output
- `maui tree` for displaying the visual tree
- `maui screenshot` for capturing screenshots
-**Decision**: Environment setup ships first. Device listing and app inspection commands
+**Decision**: Environment setup and device listing ship first. App inspection commands
follow in a future release.
## References
@@ -454,9 +619,13 @@ follow in a future release.
- [dotnet run for .NET MAUI specification][dotnet-run-spec]
- [Workload manifest specification][workload-spec]
- [AppleDev.Tools][appledev-tools] - Wraps simctl and devicectl commands
+- [ComputeAvailableDevices (Android)][compute-android] - Android workload MSBuild target
+- [ComputeAvailableDevices (Apple)][compute-apple] - Apple workload MSBuild target
- [System.CommandLine documentation](https://learn.microsoft.com/dotnet/standard/commandline/)
- [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb)
- [simctl command-line tool](https://nshipster.com/simctl/)
[vibe-wpf]: https://github.com/jonathanpeppers/vibe-wpf
[appledev-tools]: https://github.com/Redth/AppleDev.Tools
+[compute-android]: https://github.com/dotnet/android/blob/main/Documentation/docs-mobile/building-apps/build-targets.md#computeavailabledevices
+[compute-apple]: https://github.com/dotnet/macios/blob/main/docs/building-apps/build-targets.md#computeavailabledevices
diff --git a/github-merge-flow-release-11.jsonc b/github-merge-flow-release-11.jsonc
new file mode 100644
index 000000000000..e691b33db918
--- /dev/null
+++ b/github-merge-flow-release-11.jsonc
@@ -0,0 +1,10 @@
+// Update MergeToBranch when cutting a new release.
+{
+ "merge-flow-configurations": {
+ "net11.0": {
+ "MergeToBranch": "release/11.0.1xx-preview3",
+ "ExtraSwitches": "-QuietComments",
+ "ResetToTargetPaths": "global.json;NuGet.config;eng/Version.Details.xml;eng/Versions.props;eng/common/*"
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs
index 316f24d4cb3e..a86ca5fd1a01 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs
@@ -1,9 +1,10 @@
#nullable disable
+using CoreGraphics;
using UIKit;
namespace Microsoft.Maui.Controls.Platform.Compatibility
{
- internal class ShellFlyoutHeaderContainer : UIContainerView
+ internal class ShellFlyoutHeaderContainer : UIContainerView, IPlatformMeasureInvalidationController
{
Thickness _safearea = Thickness.Zero;
@@ -34,6 +35,16 @@ public override Thickness Margin
}
}
+ void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() { }
+
+ bool IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating)
+ {
+ var width = Superview?.Frame.Width ?? Frame.Width;
+ var size = SizeThatFits(new CGSize(width, double.PositiveInfinity));
+ Frame = new CGRect(Frame.X, Frame.Y, size.Width, size.Height);
+ return false;
+ }
+
public override void LayoutSubviews()
{
if (!UpdateSafeAreaMargin())
diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs
index 580652749e05..c2f8f9de38bb 100644
--- a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs
+++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs
@@ -76,6 +76,15 @@ public Page Detail
{
previousDetail.SendNavigatedFrom(
new NavigatedFromEventArgs(destinationPage: value, NavigationType.Replace));
+
+ if (previousDetail.IsLoaded)
+ {
+ previousDetail.OnUnloaded(previousDetail.DisconnectHandlers);
+ }
+ else
+ {
+ previousDetail.DisconnectHandlers();
+ }
}
_detail.SendNavigatedTo(new NavigatedToEventArgs(previousDetail, NavigationType.Replace));
diff --git a/src/Controls/src/SourceGen/CSharpExpressionHelpers.cs b/src/Controls/src/SourceGen/CSharpExpressionHelpers.cs
index f6a1cfc22bbf..9d973b0058aa 100644
--- a/src/Controls/src/SourceGen/CSharpExpressionHelpers.cs
+++ b/src/Controls/src/SourceGen/CSharpExpressionHelpers.cs
@@ -94,11 +94,11 @@ public static bool IsExplicitExpression(string? value)
{
if (string.IsNullOrEmpty(value))
return false;
-
+
var trimmed = value!.Trim();
- return trimmed.Length > 3
- && trimmed[0] == '{'
- && trimmed[1] == '='
+ return trimmed.Length > 3
+ && trimmed[0] == '{'
+ && trimmed[1] == '='
&& trimmed[trimmed.Length - 1] == '}';
}
@@ -147,8 +147,8 @@ public static bool IsImplicitExpression(string? value, Func? canRe
foreach (var op in CSharpOperators)
{
// Use case-insensitive for word-based aliases (AND, OR, LT, GT, LTE, GTE)
- var comparison = (op == " AND " || op == " OR " || op == " LT " || op == " GT " || op == " LTE " || op == " GTE ")
- ? StringComparison.OrdinalIgnoreCase
+ var comparison = (op == " AND " || op == " OR " || op == " LT " || op == " GT " || op == " LTE " || op == " GTE ")
+ ? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (trimmed.IndexOf(op, comparison) >= 0)
return true;
@@ -176,7 +176,7 @@ static bool StartsWithMarkupExtension(string trimmed, Func? canRes
return false;
var identifier = trimmed.Substring(start, end - start);
-
+
// Handle prefixed identifiers like x:Type or local:MyExtension
var colonIndex = identifier.IndexOf(':');
if (colonIndex >= 0)
@@ -210,10 +210,10 @@ internal readonly struct BareIdentifierResult
{
/// True if should be treated as C# expression, false for markup extension.
public bool IsExpression { get; init; }
-
+
/// True if both markup extension and property exist (ambiguous).
public bool IsAmbiguous { get; init; }
-
+
/// The bare identifier name (for diagnostic reporting).
public string? Name { get; init; }
}
@@ -274,11 +274,11 @@ public static BareIdentifierResult ClassifyExpression(string? markupString, Func
if (isMarkup)
{
// Markup extension wins (backward compatible), but check for ambiguity
- return new BareIdentifierResult
- {
- IsExpression = false,
- IsAmbiguous = isProperty,
- Name = name
+ return new BareIdentifierResult
+ {
+ IsExpression = false,
+ IsAmbiguous = isProperty,
+ Name = name
};
}
@@ -315,7 +315,7 @@ public static (string? prefix, string name) ParseBareIdentifier(string value)
var match = BareIdentifierPattern.Match(value.Trim());
if (!match.Success)
return (null, value);
-
+
var prefix = match.Groups[1].Success ? match.Groups[1].Value : null;
var name = match.Groups[2].Value;
return (prefix, name);
@@ -350,7 +350,7 @@ public static (string? prefix, string name) ParseBareIdentifier(string value)
static bool IsKnownMarkupExtension(string name)
{
- return KnownMarkupExtensions.Contains(name)
+ return KnownMarkupExtensions.Contains(name)
|| KnownMarkupExtensions.Contains(name + "Extension");
}
@@ -363,7 +363,7 @@ static bool IsKnownMarkupExtension(string name)
public static string GetExpressionCode(string value)
{
var trimmed = value.Trim();
-
+
// Remove outer braces
string code;
if (IsExplicitExpression(value))
@@ -394,17 +394,17 @@ static string TransformOperatorAliases(string code)
{
// Replace word-based aliases with C# operators (case-insensitive, with spaces)
var result = code;
-
+
// Logical operators
result = ReplaceWordOperator(result, " AND ", " && ");
result = ReplaceWordOperator(result, " OR ", " || ");
-
+
// Comparison operators (must do multi-char first to avoid partial replacements)
result = ReplaceWordOperator(result, " LTE ", " <= ");
result = ReplaceWordOperator(result, " GTE ", " >= ");
result = ReplaceWordOperator(result, " LT ", " < ");
result = ReplaceWordOperator(result, " GT ", " > ");
-
+
return result;
}
@@ -415,7 +415,7 @@ static string ReplaceWordOperator(string code, string word, string replacement)
{
var result = new StringBuilder();
int i = 0;
-
+
while (i < code.Length)
{
// Skip string literals (single or double quoted)
@@ -445,7 +445,7 @@ static string ReplaceWordOperator(string code, string word, string replacement)
}
continue;
}
-
+
// Check for word match (case-insensitive)
if (i + word.Length <= code.Length)
{
@@ -457,11 +457,11 @@ static string ReplaceWordOperator(string code, string word, string replacement)
continue;
}
}
-
+
result.Append(code[i]);
i++;
}
-
+
return result.ToString();
}
@@ -523,7 +523,7 @@ static string TransformQuotes(string code)
if (i < code.Length && code[i] == '\'')
{
i++; // Skip closing quote
-
+
var contentStr = content.ToString();
// Always convert to string literal (double quotes)
@@ -542,7 +542,7 @@ static string TransformQuotes(string code)
{
backslashCount++;
}
-
+
if (backslashCount % 2 == 1)
{
// Odd backslashes: quote is already escaped
@@ -609,10 +609,10 @@ public static string TransformQuotesWithSemantics(string code, Compilation compi
foreach (var literal in stringLiterals)
{
var expectedType = DetermineExpectedType(literal, compilation, contextTypes);
-
+
// If expected type is char, convert back to char literal
bool shouldBeChar = expectedType?.SpecialType == SpecialType.System_Char;
-
+
if (shouldBeChar)
{
// Get the string content and create a char literal
@@ -648,7 +648,7 @@ static string EscapeForChar(string value)
{
if (value.Length != 1)
return value;
-
+
return value[0] switch
{
'\'' => "\\'",
@@ -668,7 +668,7 @@ static string EscapeForChar(string value)
{
// Walk up to find the context
var parent = literal.Parent;
-
+
while (parent != null)
{
switch (parent)
@@ -766,19 +766,31 @@ static string EscapeForChar(string value)
///
/// Escapes a string for use in a C# string literal.
///
- static string EscapeForString(string value)
+ internal static string EscapeForString(string value)
{
var sb = new StringBuilder();
foreach (var c in value)
{
switch (c)
{
- case '"': sb.Append("\\\""); break;
- case '\\': sb.Append("\\\\"); break;
- case '\n': sb.Append("\\n"); break;
- case '\r': sb.Append("\\r"); break;
- case '\t': sb.Append("\\t"); break;
- default: sb.Append(c); break;
+ case '"':
+ sb.Append("\\\"");
+ break;
+ case '\\':
+ sb.Append("\\\\");
+ break;
+ case '\n':
+ sb.Append("\\n");
+ break;
+ case '\r':
+ sb.Append("\\r");
+ break;
+ case '\t':
+ sb.Append("\\t");
+ break;
+ default:
+ sb.Append(c);
+ break;
}
}
return sb.ToString();
diff --git a/src/Controls/src/SourceGen/KnownMarkups.cs b/src/Controls/src/SourceGen/KnownMarkups.cs
index c9ade31c9a2a..5efec5a76147 100644
--- a/src/Controls/src/SourceGen/KnownMarkups.cs
+++ b/src/Controls/src/SourceGen/KnownMarkups.cs
@@ -271,7 +271,7 @@ public static bool ProvideValueForDynamicResourceExtension(ElementNode markupNod
if (key is null)
throw new Exception();
- value = $"new global::Microsoft.Maui.Controls.Internals.DynamicResource(\"{key}\")";
+ value = $"new global::Microsoft.Maui.Controls.Internals.DynamicResource(\"{CSharpExpressionHelpers.EscapeForString(key)}\")";
return true;
}
diff --git a/src/Controls/src/SourceGen/SetPropertyHelpers.cs b/src/Controls/src/SourceGen/SetPropertyHelpers.cs
index a1c2897ca310..9d098dbace03 100644
--- a/src/Controls/src/SourceGen/SetPropertyHelpers.cs
+++ b/src/Controls/src/SourceGen/SetPropertyHelpers.cs
@@ -19,7 +19,7 @@ public static void SetPropertyValue(IndentedTextWriter writer, ILocalValue paren
if (propertyName.Equals(XmlName._CreateContent))
return; //already handled
-
+
//TODO I believe ContentProperty should be resolved here
var localName = propertyName.LocalName;
bool attached = false;
@@ -144,7 +144,8 @@ public static void AddToResourceDictionary(IndentedTextWriter writer, ILocalValu
context.KeysInRD[parentVar] = [];
context.KeysInRD[parentVar].Add((((ValueNode)keyNode).Value as string)!);
var key = ((ValueNode)keyNode).Value as string;
- writer.WriteLine($"{parentVar.ValueAccessor}[\"{key}\"] = {(getNodeValue(node, context.Compilation.ObjectType)).ValueAccessor};");
+ var escapedKey = CSharpExpressionHelpers.EscapeForString(key!);
+ writer.WriteLine($"{parentVar.ValueAccessor}[\"{escapedKey}\"] = {(getNodeValue(node, context.Compilation.ObjectType)).ValueAccessor};");
return;
}
writer.WriteLine($"{parentVar.ValueAccessor}.Add({getNodeValue(node, context.Compilation.ObjectType).ValueAccessor});");
@@ -283,7 +284,7 @@ static void ConnectEvent(IndentedTextWriter writer, ILocalValue parentVar, strin
static bool CanSetValue(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol parentType, string localName, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue, out string? explicitPropertyName)
{
explicitPropertyName = null;
-
+
if (bpFieldSymbol != null)
{
// Normal BP case - apply existing logic
@@ -311,13 +312,13 @@ static bool CanSetValue(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol par
if (localVar.Type.InheritsFrom(bpTypeAndConverter?.type!, context))
return true;
-
+
if (bpFieldSymbol.Type.IsInterface() && localVar.Type.Implements(bpTypeAndConverter?.type!))
return true;
return false;
}
-
+
// Heuristic: If BP is null but the type has a property/field with a BindablePropertyAttribute,
// assume the BP will be generated by another source generator
// Only apply this for non-BindingBase nodes (CanSetBinding handles BindingBase)
@@ -325,25 +326,25 @@ static bool CanSetValue(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol par
{
return parentType.HasBindablePropertyHeuristic(localName, context, out explicitPropertyName);
}
-
+
return false;
}
static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, string localName, string? explicitPropertyName, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue)
{
// Determine bindable property name: use BP field symbol if available, otherwise use heuristic
- var bpName = bpFieldSymbol != null
- ? bpFieldSymbol.ToFQDisplayString()
+ var bpName = bpFieldSymbol != null
+ ? bpFieldSymbol.ToFQDisplayString()
: $"{parentVar.Type.ToFQDisplayString()}.{explicitPropertyName ?? $"{localName}Property"}";
-
+
var pType = bpFieldSymbol?.GetBPTypeAndConverter(context)?.type;
var property = bpFieldSymbol == null ? parentVar.Type.GetAllProperties(localName, context).FirstOrDefault() : null;
-
+
if (node is ValueNode valueNode)
{
using (context.ProjectItem.EnableLineInfo ? PrePost.NewLineInfo(writer, (IXmlLineInfo)node, context.ProjectItem) : PrePost.NoBlock())
{
- var valueString = bpFieldSymbol != null
+ var valueString = bpFieldSymbol != null
? valueNode.ConvertTo(bpFieldSymbol, writer, context, parentVar)
: (property != null ? valueNode.ConvertTo(property, writer, context, parentVar) : getNodeValue(node, context.Compilation.ObjectType).ValueAccessor);
writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpName}, {valueString});");
@@ -355,7 +356,7 @@ static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSym
{
var localVar = getNodeValue(elementNode, context.Compilation.ObjectType);
var cast = string.Empty;
-
+
if (bpFieldSymbol != null)
{
// BP case: check for double implicit conversion first
@@ -376,7 +377,7 @@ static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSym
{
cast = $"({property.Type.ToFQDisplayString()})";
}
-
+
writer.WriteLine($"{parentVar.ValueAccessor}.SetValue({bpName}, {cast}{localVar.ValueAccessor});");
}
}
@@ -456,7 +457,7 @@ .. toType.GetMembers().OfType().Where(m => m.MethodKind == Method
{
// Check if this conversion operator can convert fromType to toType
if (SymbolEqualityComparer.Default.Equals(conversionOp.Parameters[0].Type, fromType) &&
- SymbolEqualityComparer.Default.Equals(conversionOp.ReturnType, toType))
+ SymbolEqualityComparer.Default.Equals(conversionOp.ReturnType, toType))
{
return true;
}
@@ -468,15 +469,15 @@ .. toType.GetMembers().OfType().Where(m => m.MethodKind == Method
{
var fromIsCollection = fromType.AllInterfaces.Any(i => i.ToString() == "System.Collections.IEnumerable") && fromType.SpecialType != SpecialType.System_String;
var toIsCollection = toType.AllInterfaces.Any(i => i.ToString() == "System.Collections.IEnumerable") && toType.SpecialType != SpecialType.System_String;
-
+
// Both must be collections, or both must be non-collections
if (fromIsCollection == toIsCollection)
{
// Same inheritance chain or one is an interface
if (fromType.InheritsFrom(toType, context) ||
- toType.InheritsFrom(fromType, context) ||
- toType.TypeKind == TypeKind.Interface ||
- fromType.TypeKind == TypeKind.Interface)
+ toType.InheritsFrom(fromType, context) ||
+ toType.TypeKind == TypeKind.Interface ||
+ fromType.TypeKind == TypeKind.Interface)
{
return true;
}
@@ -510,27 +511,27 @@ static void Set(IndentedTextWriter writer, ILocalValue parentVar, string localNa
static bool CanSetBinding(IFieldSymbol? bpFieldSymbol, INode node, ITypeSymbol parentType, string localName, SourceGenContext context, out string? explicitPropertyName)
{
explicitPropertyName = null;
-
+
// Check if it's a BindingBase node
if (!IsBindingBaseNode(node, context))
return false;
-
+
// If we have a BP field symbol, we can set binding
if (bpFieldSymbol != null)
return true;
-
+
// Heuristic: If BP is null but the type has a property/field with a BindablePropertyAttribute,
// assume the BP will be generated by another source generator
if (!string.IsNullOrEmpty(localName))
return parentType.HasBindablePropertyHeuristic(localName, context, out explicitPropertyName);
-
+
return false;
}
static void SetBinding(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, string localName, string? explicitPropertyName, INode node, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate getNodeValue)
{
var localVariable = getNodeValue((ElementNode)node, context.Compilation.ObjectType);
-
+
if (bpFieldSymbol != null)
{
// Normal case: we have the BP field symbol
@@ -582,9 +583,9 @@ static void Add(IndentedTextWriter writer, ILocalValue parentVar, XmlName proper
if (localName != null)
//one of those will return true, but we need the propertyType
- _ = CanGetValue(parentVar, bpFieldSymbol, attached, context, out propertyType) || CanGet(parentVar, localName, context, out propertyType, out propertySymbol);
-
- else
+ _ = CanGetValue(parentVar, bpFieldSymbol, attached, context, out propertyType) || CanGet(parentVar, localName, context, out propertyType, out propertySymbol);
+
+ else
propertyType = parentVar.Type;
if (CanAddToResourceDictionary(parentVar, propertyType!, (ElementNode)valueNode, context, getNodeValue))
@@ -594,7 +595,7 @@ static void Add(IndentedTextWriter writer, ILocalValue parentVar, XmlName proper
rdAccessor = new DirectValue(propertyType!, GetOrGetValue(parentVar, bpFieldSymbol, propertySymbol, valueNode, context));
else
rdAccessor = parentVar;
-
+
AddToResourceDictionary(writer, rdAccessor, (ElementNode)valueNode, context, getNodeValue);
return;
}
@@ -684,7 +685,7 @@ static bool TryHandleExpressionBinding(IndentedTextWriter writer, ILocalValue pa
context.ReportDiagnostic(Diagnostic.Create(Descriptors.AmbiguousMemberExpression, bothLocation, resolution.RootIdentifier, context.RootType?.Name ?? "this", dataTypeSymbol.Name));
return true; // Handled (with error)
}
-
+
// Warn if member name conflicts with a well-known static type
if (resolution.ConflictsWithStaticType)
{
@@ -695,8 +696,8 @@ static bool TryHandleExpressionBinding(IndentedTextWriter writer, ILocalValue pa
}
// Handle not-found case for simple identifiers
- if (resolution.Location == MemberLocation.Neither &&
- !string.IsNullOrEmpty(resolution.RootIdentifier) &&
+ if (resolution.Location == MemberLocation.Neither &&
+ !string.IsNullOrEmpty(resolution.RootIdentifier) &&
MemberResolver.IsSimpleIdentifier(expression.Code))
{
var neitherLocation = LocationCreate(context.ProjectItem.RelativePath!, (IXmlLineInfo)valueNode, expression.Code);
@@ -741,7 +742,7 @@ static bool TryHandleExpressionBinding(IndentedTextWriter writer, ILocalValue pa
static void SetExpressionBinding(IndentedTextWriter writer, ILocalValue parentVar, IFieldSymbol bpFieldSymbol, string expression, ITypeSymbol dataTypeSymbol, SourceGenContext context, ValueNode valueNode)
{
var bpName = bpFieldSymbol.ToFQDisplayString();
-
+
var sourceTypeName = dataTypeSymbol.ToFQDisplayString();
// Transform quotes with semantic context - char literals stay as char only if target expects char
@@ -786,7 +787,7 @@ static void SetExpressionBinding(IndentedTextWriter writer, ILocalValue parentVa
if (getterExpression.Contains("?."))
getterExpression += "!";
writer.WriteLine($"__source => ({getterExpression}, true),");
-
+
// Generate setter if expression is a simple property chain AND the terminal property is writable
if (analysis.IsSettable && IsExpressionWritable(expression, dataTypeSymbol, context))
{
@@ -806,7 +807,7 @@ static void SetExpressionBinding(IndentedTextWriter writer, ILocalValue parentVa
bpFieldSymbol.Name));
}
}
-
+
// Generate handlers array
if (handlers.Count == 0)
{
diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs
index 5a804343d967..93e9a4fe3a34 100644
--- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs
+++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs
@@ -89,9 +89,11 @@ void SetupBuilder()
#if IOS || MACCATALYST
handlers.AddHandler();
handlers.AddHandler();
+ handlers.AddHandler();
#else
handlers.AddHandler();
handlers.AddHandler();
+ handlers.AddHandler();
#endif
});
});
@@ -148,6 +150,52 @@ await CreateHandlerAndAddToWindow(new Window(navPage), async () =>
await AssertionExtensions.WaitForGC(references.ToArray());
}
+ #if ANDROID
+ [Fact("FlyoutPage Detail Navigation Does Not Leak")]
+ public async Task FlyoutPageDetailNavigationDoesNotLeak()
+ {
+ SetupBuilder();
+
+ var references = new List();
+
+ var initialDetail = new NavigationPage(new ContentPage { Title = "Initial Detail" });
+
+ var flyoutPage = new FlyoutPage
+ {
+ Flyout = new ContentPage { Title = "Flyout" },
+ Detail = initialDetail
+ };
+
+ await CreateHandlerAndAddToWindow(new Window(flyoutPage), async () =>
+ {
+ for (int i = 0; i < 4; i++)
+ {
+ var detailPage = new ContentPage
+ {
+ Title = $"Detail {i}",
+ Content = new Label { Text = $"Content {i}" }
+ };
+ var navPage = new NavigationPage(detailPage);
+
+ flyoutPage.Detail = navPage;
+ flyoutPage.IsPresented = false;
+
+ await OnLoadedAsync(detailPage);
+
+ references.Add(new(detailPage));
+ references.Add(new(navPage));
+ }
+ });
+
+
+ // The last page will be alive and attached to the FlyoutPage
+ references.RemoveAt(references.Count - 1);
+ references.RemoveAt(references.Count - 1);
+
+ await AssertionExtensions.WaitForGC(references.ToArray());
+ }
+#endif
+
[Theory("Handler Does Not Leak")]
[InlineData(typeof(ActivityIndicator))]
[InlineData(typeof(Border))]
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/HeaderFooterShellFlyout.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/HeaderFooterShellFlyout.cs
index 1dd8dac3078a..152c52e5cc9c 100644
--- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/HeaderFooterShellFlyout.cs
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/HeaderFooterShellFlyout.cs
@@ -66,9 +66,8 @@ public void AFlyoutTests()
App.WaitForNoElement("Footer");
// verify header and footer react to size changes
- // These tests are ignored on iOS and Catalyst because the header height doesn't update correctly. Refer to issue: https://github.com/dotnet/maui/issues/26397
// On Windows, the stack layout's AutomationId isn't behaving as expected, so the Y position of the first flyout item is used to verify header and footer sizes.
-#if ANDROID
+#if ANDROID || IOS || MACCATALYST
App.Tap(ResizeHeaderFooter);
var headerSizeSmall = App.WaitForElement("HeaderView").GetRect();
diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml
new file mode 100644
index 000000000000..b621d073f473
--- /dev/null
+++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs
new file mode 100644
index 000000000000..098fb9051b21
--- /dev/null
+++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Globalization;
+using Microsoft.Maui.ApplicationModel;
+using Microsoft.Maui.Controls.Core.UnitTests;
+using Microsoft.Maui.Dispatching;
+using Microsoft.Maui.UnitTests;
+using Xunit;
+
+using static Microsoft.Maui.Controls.Xaml.UnitTests.MockSourceGenerator;
+
+namespace Microsoft.Maui.Controls.Xaml.UnitTests;
+
+public partial class Maui34713 : ContentPage
+{
+ public Maui34713()
+ {
+ InitializeComponent();
+ }
+
+ [Collection("Issue")]
+ public class Tests : IDisposable
+ {
+ public Tests()
+ {
+ DispatcherProvider.SetCurrent(new DispatcherProviderStub());
+ }
+
+ public void Dispose()
+ {
+ AppInfo.SetCurrent(null);
+ Application.SetCurrentApplication(null);
+ DispatcherProvider.SetCurrent(null);
+ }
+
+ const string SharedCs = @"
+using System;
+using System.Globalization;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Xaml;
+namespace Microsoft.Maui.Controls.Xaml.UnitTests
+{
+public class Maui34713ViewModel { public bool IsActive { get; set; } public string Name { get; set; } = """"; }
+public class Maui34713BoolToTextConverter : IValueConverter {
+ public object Convert(object v, Type t, object p, CultureInfo c) => v is true ? ""Active"" : ""Inactive"";
+ public object ConvertBack(object v, Type t, object p, CultureInfo c) => throw new NotImplementedException();
+ }
+}";
+
+[Fact]
+internal void SourceGenResolvesConverterAtCompileTime_ImplicitResources()
+{
+ // When converter IS in page resources (implicit), source gen should
+ // resolve it at compile time - no runtime ProvideValue needed.
+ var xaml = @"
+
+
+
+
+
+
+
+ ";
+
+ var cs = @"
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Xaml;
+namespace Microsoft.Maui.Controls.Xaml.UnitTests
+{
+[XamlProcessing(XamlInflator.Runtime, true)]
+public partial class Maui34713Test1 : ContentPage { public Maui34713Test1() { InitializeComponent(); } }
+ }" + SharedCs;
+
+ var result = CreateMauiCompilation()
+ .WithAdditionalSource(cs, hintName: "Maui34713Test1.xaml.cs")
+ .RunMauiSourceGenerator(new AdditionalXamlFile("Issues/Maui34713Test1.xaml", xaml, TargetFramework: "net10.0"));
+
+ var generated = result.GeneratedInitializeComponent();
+
+ Assert.Contains("TypedBinding", generated, StringComparison.Ordinal);
+ // Converter should be resolved at compile time - no ProvideValue call
+ Assert.DoesNotContain(".ProvideValue(", generated, StringComparison.Ordinal);
+}
+
+[Fact]
+internal void SourceGenResolvesConverterAtCompileTime_ExplicitResourceDictionary()
+{
+ // When converter IS in page resources (explicit RD), source gen should
+ // also resolve it at compile time.
+ var xaml = @"
+
+
+
+
+
+
+
+
+
+ ";
+
+ var cs = @"
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Xaml;
+namespace Microsoft.Maui.Controls.Xaml.UnitTests
+{
+[XamlProcessing(XamlInflator.Runtime, true)]
+public partial class Maui34713Test2 : ContentPage { public Maui34713Test2() { InitializeComponent(); } }
+ }" + SharedCs;
+
+ var result = CreateMauiCompilation()
+ .WithAdditionalSource(cs, hintName: "Maui34713Test2.xaml.cs")
+ .RunMauiSourceGenerator(new AdditionalXamlFile("Issues/Maui34713Test2.xaml", xaml, TargetFramework: "net10.0"));
+
+ var generated = result.GeneratedInitializeComponent();
+
+ Assert.Contains("TypedBinding", generated, StringComparison.Ordinal);
+ // Converter should be resolved at compile time - no ProvideValue call
+ Assert.DoesNotContain(".ProvideValue(", generated, StringComparison.Ordinal);
+}
+
+[Fact]
+internal void SourceGenCompilesBindingWithConverterToTypedBinding()
+{
+ // When the converter is NOT in page resources, the binding should
+ // still be compiled into a TypedBinding.
+ var result = CreateMauiCompilation()
+ .WithAdditionalSource(
+ @"using System;
+ using System.Globalization;
+ using Microsoft.Maui.Controls;
+ using Microsoft.Maui.Controls.Xaml;
+
+ namespace Microsoft.Maui.Controls.Xaml.UnitTests;
+
+ [XamlProcessing(XamlInflator.Runtime, true)]
+ public partial class Maui34713 : ContentPage
+ {
+ public Maui34713() => InitializeComponent();
+ }
+
+ public class Maui34713ViewModel
+ {
+ public bool IsActive { get; set; }
+ public string Name { get; set; } = string.Empty;
+ }
+
+ public class Maui34713BoolToTextConverter : IValueConverter
+ {
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is true ? ""Active"" : ""Inactive"";
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+ }
+ ")
+ .RunMauiSourceGenerator(typeof(Maui34713));
+
+ var generated = result.GeneratedInitializeComponent();
+
+ Assert.Contains("TypedBinding", generated, StringComparison.Ordinal);
+ Assert.Contains("Converter = extension.Converter", generated, StringComparison.Ordinal);
+ Assert.DoesNotContain("new global::Microsoft.Maui.Controls.Binding(", generated, StringComparison.Ordinal);
+}
+
+[Theory]
+[XamlInflatorData]
+internal void BindingWithConverterFromAppResourcesWorksCorrectly(XamlInflator inflator)
+{
+ var mockApp = new MockApplication();
+ mockApp.Resources.Add("BoolToTextConverter", new Maui34713BoolToTextConverter());
+ Application.SetCurrentApplication(mockApp);
+
+ var page = new Maui34713(inflator);
+ page.BindingContext = new Maui34713ViewModel { IsActive = true, Name = "Test" };
+
+ Assert.Equal("Active", page.label0.Text);
+ Assert.Equal("Test", page.label1.Text);
+}
+}
+}
+
+#nullable enable
+
+public class Maui34713ViewModel
+{
+ public bool IsActive { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
+
+public class Maui34713BoolToTextConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is true ? "Active" : "Inactive";
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+}
diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml
new file mode 100644
index 000000000000..d601f6b33dd0
--- /dev/null
+++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+ Red
+ Blue
+ Green
+
+
+
diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml.cs
new file mode 100644
index 000000000000..82d581cb5864
--- /dev/null
+++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34726.xaml.cs
@@ -0,0 +1,44 @@
+using Xunit;
+using static Microsoft.Maui.Controls.Xaml.UnitTests.MockSourceGenerator;
+
+namespace Microsoft.Maui.Controls.Xaml.UnitTests;
+
+public partial class Maui34726 : ContentPage
+{
+ public Maui34726() => InitializeComponent();
+
+ [Collection("Issue")]
+ public class Tests
+ {
+ [Theory]
+ [XamlInflatorData]
+ internal void XKeyWithSpecialCharsProducesValidCode(XamlInflator inflator)
+ {
+ if (inflator == XamlInflator.SourceGen)
+ {
+ var result = CreateMauiCompilation()
+ .WithAdditionalSource(
+"""
+namespace Microsoft.Maui.Controls.Xaml.UnitTests;
+
+[XamlProcessing(XamlInflator.Runtime, true)]
+public partial class Maui34726 : ContentPage
+{
+ public Maui34726() => InitializeComponent();
+}
+""")
+ .RunMauiSourceGenerator(typeof(Maui34726));
+ Assert.Empty(result.Diagnostics);
+ }
+ else
+ {
+ var page = new Maui34726(inflator);
+ Assert.NotNull(page);
+ Assert.Equal(3, page.Resources.Count);
+ Assert.True(page.Resources.ContainsKey("Key\"Quote"));
+ Assert.True(page.Resources.ContainsKey("Key\\Backslash"));
+ Assert.True(page.Resources.ContainsKey("SimpleKey"));
+ }
+ }
+ }
+}
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
index fac7d6e86772..97f4e0eb75f5 100644
--- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
+++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs
@@ -70,8 +70,11 @@ void UpdateDetailsFragmentView()
if (context is null)
return;
- if (VirtualView.Detail?.Handler is IPlatformViewHandler pvh)
- pvh.DisconnectHandler();
+ if (_detailViewFragment?.DetailView is IView previousDetail &&
+ previousDetail != VirtualView.Detail)
+ {
+ previousDetail.Handler?.DisconnectHandler();
+ }
var fragmentManager = MauiContext.GetFragmentManager();
diff --git a/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs b/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
index 496990dee1a7..a5fd1a999391 100644
--- a/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
+++ b/src/Core/src/Platform/Android/Navigation/NavigationViewFragment.cs
@@ -89,6 +89,7 @@ public override void OnDestroy()
{
_currentView = null;
_fragmentContainerView = null;
+ _navigationManager = null;
base.OnDestroy();
}