From 449408263777a140d35123e0f417120a6f27c908 Mon Sep 17 00:00:00 2001
From: devanathan-vaithiyanathan
<114395405+devanathan-vaithiyanathan@users.noreply.github.com>
Date: Thu, 19 Mar 2026 16:56:18 +0530
Subject: [PATCH 1/5] fix added
---
.../Android/ShellFragmentStateAdapter.cs | 20 +++++
.../Shell/Android/ShellSectionRenderer.cs | 10 +++
.../Shell/iOS/ShellSectionRootRenderer.cs | 79 +++++++++++++++++++
.../Shell/ShellSectionHandler.Windows.cs | 32 +++++++-
4 files changed, 140 insertions(+), 1 deletion(-)
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
index a11fd787ee55..9445ef6df23e 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
@@ -44,6 +44,26 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent
_createdShellContent.Remove(remove);
}
+ ///
+ /// Invalidates the cached item ID for the given so that
+ /// existing fragment and recreate it with the updated page content.
+ ///
+ public void InvalidateShellContent(ShellContent shellContent)
+ {
+ long removeKey = -1;
+ foreach (var item in _createdShellContent)
+ {
+ if (item.Value == shellContent)
+ {
+ removeKey = item.Key;
+ break;
+ }
+ }
+
+ if (removeKey >= 0)
+ _createdShellContent.Remove(removeKey);
+ }
+
public int CountOverride { get; set; }
public override int ItemCount => _items.Count;
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
index a3f4513ccaf2..5cef963b241b 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
@@ -161,6 +161,16 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property
{
UpdateTabTitle(shellContent);
}
+ else if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent changedContent)
+ {
+ // The page inside this ShellContent changed — force ViewPager2 to recreate the
+ // fragment so it picks up the new content.
+ if (_viewPager?.Adapter is ShellFragmentStateAdapter adapter)
+ {
+ adapter.InvalidateShellContent(changedContent);
+ SafeNotifyDataSetChanged();
+ }
+ }
}
void UpdateTabTitle(ShellContent shellContent)
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
index 6d880ec642a6..234ddc69c4d6 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
@@ -101,6 +101,9 @@ public override void ViewDidLoad()
ShellSection.PropertyChanged += OnShellSectionPropertyChanged;
ShellSectionController.ItemsCollectionChanged += OnShellSectionItemsChanged;
+ foreach (var item in ShellSectionController.GetItems())
+ item.PropertyChanged += OnShellContentPropertyChanged;
+
_blurView = new UIView();
UIVisualEffect blurEffect = UIBlurEffect.FromStyle(UIBlurEffectStyle.ExtraLight);
_blurView = new UIVisualEffectView(blurEffect);
@@ -151,6 +154,11 @@ void IDisconnectable.Disconnect()
if (ShellSectionController != null)
ShellSectionController.ItemsCollectionChanged -= OnShellSectionItemsChanged;
+ var shellContents = ShellSectionController?.GetItems();
+ if (shellContents != null)
+ foreach (var item in shellContents)
+ item.PropertyChanged -= OnShellContentPropertyChanged;
+
if (_shellContext?.Shell != null)
_shellContext.Shell.PropertyChanged -= HandleShellPropertyChanged;
@@ -500,6 +508,8 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs
{
foreach (ShellContent oldItem in e.OldItems)
{
+ oldItem.PropertyChanged -= OnShellContentPropertyChanged;
+
// if current item is removed will be handled by the currentitem property changed event
// That way the render is swapped out cleanly once the new current item is set
if (_currentContent == oldItem)
@@ -524,6 +534,8 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs
{
foreach (ShellContent newItem in e.NewItems)
{
+ newItem.PropertyChanged += OnShellContentPropertyChanged;
+
if (_renderers.ContainsKey(newItem))
continue;
@@ -535,6 +547,73 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs
}
}
+ void OnShellContentPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (_isDisposed)
+ return;
+
+ if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent shellContent)
+ {
+ // PropertyChanged fires BEFORE the BindableProperty's propertyChanged callback
+ // (OnContentChanged), so ContentCache may not be updated yet.
+ if (shellContent.Content is Page newPage)
+ {
+ // Content is a Page — available immediately, no deferral needed.
+ UpdateRendererForShellContent(shellContent, newPage);
+ }
+ else if (shellContent.Content is DataTemplate)
+ {
+ // Content is a DataTemplate — ContentCache is populated by OnContentChanged,
+ // which runs after PropertyChanged completes. Defer to the next run-loop
+ // iteration so GetOrCreateContent() sees the updated ContentCache.
+ BeginInvokeOnMainThread(() =>
+ {
+ if (_isDisposed)
+ return;
+ var page = ((IShellContentController)shellContent).GetOrCreateContent();
+ UpdateRendererForShellContent(shellContent, page);
+ });
+ }
+ }
+ }
+
+ void UpdateRendererForShellContent(ShellContent shellContent, Page newPage)
+ {
+ if (newPage == null)
+ return;
+
+ if (!_renderers.TryGetValue(shellContent, out var oldRenderer))
+ return;
+
+ // If the existing renderer is already showing the new page, nothing to do.
+ if (oldRenderer.VirtualView == newPage)
+ return;
+
+ bool isCurrentContent = shellContent == _currentContent;
+
+ // Remove the old renderer
+ if (isCurrentContent)
+ oldRenderer.ViewController?.ViewIfLoaded?.RemoveFromSuperview();
+
+ oldRenderer.ViewController?.RemoveFromParentViewController();
+ oldRenderer.DisconnectHandler();
+ _renderers.Remove(shellContent);
+
+ // Create a new renderer for the updated page
+ var renderer = SetPageRenderer(newPage, shellContent);
+ AddChildViewController(renderer.ViewController);
+
+ if (isCurrentContent)
+ {
+ _containerArea.AddSubview(renderer.ViewController.View);
+ renderer.ViewController.View.Frame = _containerArea.Bounds;
+ UpdateAdditionalSafeAreaInsets(renderer);
+
+ if (_tracker != null)
+ _tracker.Page = newPage;
+ }
+ }
+
IPlatformViewHandler SetPageRenderer(Page page, ShellContent shellContent)
{
page.Handler?.DisconnectHandler();
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
index 5c930f718b76..906a7ee0c691 100644
--- a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
+++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
@@ -53,6 +53,7 @@ public override void SetVirtualView(Maui.IElement view)
{
if (_shellSection != null)
{
+ ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this);
((IShellSectionController)_shellSection).NavigationRequested -= OnNavigationRequested;
((IShellSectionController)_shellSection).ItemsCollectionChanged -= OnItemsCollectionChanged;
@@ -84,7 +85,6 @@ public override void SetVirtualView(Maui.IElement view)
if (_shellSection != null)
{
((IShellSectionController)_shellSection).NavigationRequested += OnNavigationRequested;
-
((IShellSectionController)_shellSection).ItemsCollectionChanged += OnItemsCollectionChanged;
var shell = _shellSection.FindParentOfType() as IShellController;
@@ -93,9 +93,36 @@ public override void SetVirtualView(Maui.IElement view)
_lastShell = new WeakReference(shell);
shell.AddAppearanceObserver(this, _shellSection);
}
+
+ // AddDisplayedPageObserver immediately invokes the callback with the current page,
+ // but at that point PendingNavigationTask is already set from MapCurrentItem
+ // (which runs via base.SetVirtualView above), so it is safely skipped.
+ ((IShellSectionController)_shellSection).AddDisplayedPageObserver(this, OnDisplayedPageChanged);
}
}
+ // Called when ShellSection.DisplayedPage changes — covers both content changes and
+ // navigation pushes. We only act on content changes (stack depth == 1, no pending nav).
+ void OnDisplayedPageChanged(Page? page)
+ {
+ if (page is null || VirtualView is null)
+ return;
+
+ // Push/pop navigation is handled by OnNavigationRequested; skip those cases.
+ if (VirtualView.Stack.Count > 1)
+ return;
+
+ // Tab switches are handled by MapCurrentItem which sets PendingNavigationTask first
+ // (because the property mapper fires before the propertyChanged callback that calls
+ // UpdateDisplayedPage). Skip when another navigation is already in flight.
+ if (VirtualView.PendingNavigationTask != null)
+ return;
+
+ // ContentCache has been updated by OnContentChanged at this point, so
+ // SyncNavigationStack will correctly pick up the new page via GetOrCreateContent().
+ SyncNavigationStack(false, null);
+ }
+
void OnNavigationRequested(object? sender, NavigationRequestedEventArgs e)
{
SyncNavigationStack(e.Animated, e);
@@ -159,6 +186,9 @@ protected override void ConnectHandler(WFrame platformView)
protected override void DisconnectHandler(WFrame platformView)
{
+ if (_shellSection != null)
+ ((IShellSectionController)_shellSection).RemoveDisplayedPageObserver(this);
+
_navigationManager?.Disconnect(VirtualView, platformView);
base.DisconnectHandler(platformView);
}
From fa52f4bf20e21bf086c2bea406e4baee38178cce Mon Sep 17 00:00:00 2001
From: devanathan-vaithiyanathan
<114395405+devanathan-vaithiyanathan@users.noreply.github.com>
Date: Tue, 24 Mar 2026 19:21:24 +0530
Subject: [PATCH 2/5] test case added
---
.../Android/ShellFragmentStateAdapter.cs | 2 +-
.../TestCases.HostApp/Issues/Issue12669.cs | 61 +++++++++++++++++++
.../Tests/Issues/Issue12669.cs | 27 ++++++++
3 files changed, 89 insertions(+), 1 deletion(-)
create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs
create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
index 9445ef6df23e..44af4390c987 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
@@ -48,7 +48,7 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent
/// Invalidates the cached item ID for the given so that
/// existing fragment and recreate it with the updated page content.
///
- public void InvalidateShellContent(ShellContent shellContent)
+ internal void InvalidateShellContent(ShellContent shellContent)
{
long removeKey = -1;
foreach (var item in _createdShellContent)
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs
new file mode 100644
index 000000000000..fd46de83e64f
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue12669.cs
@@ -0,0 +1,61 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 12669, "Changing Content property of ShellContent doesn't change visual content", PlatformAffected.All)]
+public class Issue12669 : TestShell
+{
+ protected override void Init()
+ {
+ FlyoutBehavior = FlyoutBehavior.Disabled;
+
+ var shellContent = new ShellContent { Title = "Test" };
+
+ var changeButton = new Button
+ {
+ Text = "Change Content",
+ AutomationId = "ChangeContentButton"
+ };
+
+ shellContent.Content = new ContentPage
+ {
+ Content = new VerticalStackLayout
+ {
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = LayoutOptions.Center,
+ Children =
+ {
+ new Label
+ {
+ Text = "Original Content",
+ AutomationId = "OriginalContent"
+ },
+ changeButton
+ }
+ }
+ };
+
+ changeButton.Clicked += (s, e) =>
+ {
+ shellContent.ContentTemplate = null;
+ shellContent.Content = new ContentPage
+ {
+ Content = new VerticalStackLayout
+ {
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = LayoutOptions.Center,
+ Children =
+ {
+ new Label
+ {
+ Text = "Content Changed",
+ AutomationId = "NewContent"
+ }
+ }
+ }
+ };
+ };
+
+ var tab = new Tab { Title = "Main" };
+ tab.Items.Add(shellContent);
+ Items.Add(tab);
+ }
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs
new file mode 100644
index 000000000000..24a13f0e7b2a
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue12669.cs
@@ -0,0 +1,27 @@
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+public class Issue12669 : _IssuesUITest
+{
+ public Issue12669(TestDevice device) : base(device) { }
+
+ public override string Issue => "Changing Content property of ShellContent doesn't change visual content";
+
+ [Test]
+ [Category(UITestCategories.Shell)]
+ public void ShellContentShouldUpdateWhenContentPropertyChanges()
+ {
+ App.WaitForElement("OriginalContent");
+ App.WaitForElement("ChangeContentButton");
+
+ App.Tap("ChangeContentButton");
+
+ // After clicking, the ShellContent.Content is changed to a new page.
+ // The visual should update to show the new content.
+ App.WaitForElement("NewContent");
+ var labelText = App.FindElement("NewContent").GetText();
+ Assert.That(labelText, Is.EqualTo("Content Changed"));
+ }
+}
From 41267adc08e908d7e0de8f307e28721ce854a170 Mon Sep 17 00:00:00 2001
From: devanathan-vaithiyanathan
<114395405+devanathan-vaithiyanathan@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:36:34 +0530
Subject: [PATCH 3/5] comment updated
---
.../Handlers/Shell/iOS/ShellSectionRootRenderer.cs | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
index 234ddc69c4d6..ae0d5bd0cec9 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs
@@ -554,8 +554,9 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property
if (e.PropertyName == ShellContent.ContentProperty.PropertyName && sender is ShellContent shellContent)
{
- // PropertyChanged fires BEFORE the BindableProperty's propertyChanged callback
- // (OnContentChanged), so ContentCache may not be updated yet.
+ // INotifyPropertyChanged.PropertyChanged fires AFTER the BindableProperty's
+ // propertyChanged callback (OnContentChanged) in BindableObject.SetValueCore.
+ // For a Page, ContentCache is already updated by the time we get here.
if (shellContent.Content is Page newPage)
{
// Content is a Page — available immediately, no deferral needed.
@@ -564,8 +565,9 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property
else if (shellContent.Content is DataTemplate)
{
// Content is a DataTemplate — ContentCache is populated by OnContentChanged,
- // which runs after PropertyChanged completes. Defer to the next run-loop
- // iteration so GetOrCreateContent() sees the updated ContentCache.
+ // which has already run before this PropertyChanged notification. However,
+ // defer to the next run-loop iteration to ensure any async post-processing
+ // in GetOrCreateContent() sees a fully settled ContentCache.
BeginInvokeOnMainThread(() =>
{
if (_isDisposed)
From 8b7624ec83a04978b1e65042a716e695f9f4fbb5 Mon Sep 17 00:00:00 2001
From: devanathan-vaithiyanathan
<114395405+devanathan-vaithiyanathan@users.noreply.github.com>
Date: Mon, 6 Apr 2026 11:46:25 +0530
Subject: [PATCH 4/5] AI suggestion addressed
---
.../Handlers/Shell/Android/ShellFragmentStateAdapter.cs | 5 +++--
.../src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs | 4 ++--
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
index 44af4390c987..f371e69d45be 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFragmentStateAdapter.cs
@@ -45,8 +45,9 @@ public void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEvent
}
///
- /// Invalidates the cached item ID for the given so that
- /// existing fragment and recreate it with the updated page content.
+ /// Invalidates the cached item ID for the given .
+ /// This causes the existing fragment for that item to be destroyed and a new fragment
+ /// to be created with the updated page content.
///
internal void InvalidateShellContent(ShellContent shellContent)
{
diff --git a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
index 906a7ee0c691..c215ea12c12a 100644
--- a/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
+++ b/src/Controls/src/Core/Handlers/Shell/ShellSectionHandler.Windows.cs
@@ -103,9 +103,9 @@ public override void SetVirtualView(Maui.IElement view)
// Called when ShellSection.DisplayedPage changes — covers both content changes and
// navigation pushes. We only act on content changes (stack depth == 1, no pending nav).
- void OnDisplayedPageChanged(Page? page)
+ void OnDisplayedPageChanged(Page page)
{
- if (page is null || VirtualView is null)
+ if (VirtualView is null)
return;
// Push/pop navigation is handled by OnNavigationRequested; skip those cases.
From 66f06f4fe15e61c746685bbf009c2d4f853e9314 Mon Sep 17 00:00:00 2001
From: devanathan-vaithiyanathan
<114395405+devanathan-vaithiyanathan@users.noreply.github.com>
Date: Mon, 25 May 2026 15:42:28 +0530
Subject: [PATCH 5/5] AI summary added
---
.../Handlers/Shell/Android/ShellSectionRenderer.cs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
index 5cef963b241b..c2baa50807c0 100644
--- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
+++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs
@@ -169,6 +169,16 @@ void OnShellContentPropertyChanged(object sender, System.ComponentModel.Property
{
adapter.InvalidateShellContent(changedContent);
SafeNotifyDataSetChanged();
+
+ // Keep toolbar state in sync when the active tab's content page is replaced.
+ if (ShellSection?.CurrentItem == changedContent && _toolbarTracker is not null)
+ {
+ var page = ((IShellContentController)changedContent).GetOrCreateContent();
+ if (page is not null)
+ {
+ _toolbarTracker.Page = page;
+ }
+ }
}
}
}