Skip to content

Commit e855a8d

Browse files
[iOS] Fix ObjectDisposedException in TraitCollectionDidChange on window disposal (#33353)
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Description of Change Fixes an intermittent `ObjectDisposedException` crash when exiting MacCatalyst/iOS apps. **Root cause:** When a window closes, `Window.Destroying()` disposes the service provider scope, then iOS/MacCatalyst calls `TraitCollectionDidChange` on view controllers. The override in `ShellSectionRootRenderer` tried to access disposed services, causing the crash. **Architectural improvement:** This PR removes duplicate theme handling code: 1. **REMOVED** `TraitCollectionDidChange` override from `ShellSectionRootRenderer` (Controls layer - Shell-specific) 2. **ENHANCED** `TraitCollectionDidChange` in `PageViewController` (Core layer - applies to ALL pages) with: - `window?.Handler == null` check to detect window destruction before accessing services - try-catch safety net for race conditions **Why this approach:** - Core implementation handles theme changes for all pages (not just Shell) - Window.Handler check proactively detects teardown phase (Handler disconnects before service disposal) - try-catch provides safety net for potential race conditions - Eliminates code duplication across layers **Test added:** `Issue33352` test verifies no crash when `TraitCollectionDidChange` called after window disposal. ### Issues Fixed Fixes #33352 ``` --- ## What Changed from Original | Section | Original | Recommended | Why | |---------|----------|-------------|-----| | **NOTE block** | Missing | Added | Required for user testing | | **Root cause** | Brief mention | Detailed window disposal sequence | Helps future developers understand timing | | **Implementation** | "disabled/removed" | Two-part architectural improvement | Accurately describes both removal AND enhancement | | **PageViewController** | Not mentioned | Detailed enhancement with checks | This is half the fix - must be documented | | **Rationale** | Not provided | "Why this approach" section | Explains architectural decision | | **Test** | Not mentioned | Mentioned with test name | Documents test coverage | --- --------- Co-authored-by: Shane Neuville <shneuvil@microsoft.com>
1 parent 7015af8 commit e855a8d

File tree

7 files changed

+952
-17
lines changed

7 files changed

+952
-17
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Issue #33352 - Fix Exploration Session
2+
3+
**Issue:** Intermittent crash on exit on MacCatalyst - ObjectDisposedException in ShellSectionRootRenderer
4+
**Platform:** MacCatalyst
5+
**Test Filter:** Issue33352
6+
**Bug:** `TraitCollectionDidChange` is called on disposed ShellSectionRootRenderer after window scope disposed
7+
8+
## Reproduction
9+
10+
**100% reproducible** with test: `TraitCollectionDidChangeAfterDisposeDoesNotCrash`
11+
12+
**Error:** `ObjectDisposedException: Cannot access a disposed object. Object name: 'IServiceProvider'.`
13+
14+
**Root Cause:**
15+
- Window is closed, which disposes the service provider scope
16+
- iOS/MacCatalyst then calls `TraitCollectionDidChange` on the ShellSectionRootRenderer
17+
- The override tries to access `Context.Services.GetService<IApplication>()`
18+
- The service provider has been disposed, throwing ObjectDisposedException
19+
20+
**Affected Code:**
21+
- `src/Controls/src/Core/Platform/iOS/ShellSectionRootRenderer.cs` - TraitCollectionDidChange override
22+
23+
## Fix Candidates
24+
25+
| # | Source | Approach | Test Result | Files Changed | Model | Notes |
26+
|---|--------|----------|-------------|---------------|-------|-------|
27+
| 1 | try-fix | Check `_isDisposed` before accessing services | ❌ FAIL | `ShellSectionRootRenderer.cs` (+5) | Claude Sonnet 4.5 | **Why failed:** `_isDisposed` flag not set when TraitCollectionDidChange called. Window scope disposed first, then iOS calls TraitCollectionDidChange, but renderer's Dispose() hasn't been called yet. The ServiceProvider is disposed before the renderer knows it's disposed. |
28+
| 2 | try-fix | Store `IApplication` in `WeakReference` | ✅ PASS | `ShellSectionRootRenderer.cs` (+13) | Claude Sonnet 4.5 | **Why works:** Captures IApplication at construction time before service provider disposed. WeakReference allows GC to collect if needed. No ObjectDisposedException because we never touch the disposed service provider. |
29+
| 3 | try-fix | Use `Application.Current` instead of service provider | ✅ PASS | `ShellSectionRootRenderer.cs` (+12) | Claude Sonnet 4.5 | **Why works:** `Application.Current` is a static property that doesn't go through the scoped service provider. Cast to `IApplication` to call `ThemeChanged()`. Simpler than WeakReference approach. **PREFERRED - simplest solution.** |
30+
| 4 | try-fix | Try-catch `ObjectDisposedException` | ✅ PASS | `ShellSectionRootRenderer.cs` (+14) | Claude Sonnet 4.5 | **Why works:** Catches and swallows ObjectDisposedException. Theme change not critical during disposal. Works but less elegant than avoiding the exception. |
31+
| 5 | try-fix | Remove override entirely | ❌ N/A | - | Claude Sonnet 4.5 | **Not applicable:** Method is in PublicAPI.Shipped.txt, removing it would be breaking change. Would require API removal process. |
32+
| 6 | try-fix | Null-check `FindMauiContext()` + try-catch | ✅ PASS | `ShellSectionRootRenderer.cs` (+19) | Claude Sonnet 4.5 | **Why works:** Double protection - null check first, then try-catch. More defensive but verbose. |
33+
| 7 | try-fix | Check if Shell's Window is null | ❌ FAIL | `ShellSectionRootRenderer.cs` (+15) | Claude Sonnet 4.5 | **Why failed:** Window property is still set when TraitCollectionDidChange called. Window.Parent disconnection happens after TraitCollectionDidChange, so checking Window is null doesn't help. |
34+
| 8 | try-fix | Check if Window.Handler is null | ✅ PASS | `ShellSectionRootRenderer.cs` (+16) | Claude Sonnet 4.5 | **Why works:** Window.Handler is disconnected before service provider disposed. Checking `window?.Handler == null` catches the disconnection state. Good approach for detecting window closure. |
35+
| 9 | try-fix | Check if Shell.Parent is null | ❌ FAIL | `ShellSectionRootRenderer.cs` (+15) | Claude Sonnet 4.5 | **Why failed:** Shell.Parent (Window) still set when TraitCollectionDidChange called. Shell remains attached to Window during disposal sequence. |
36+
| 10 | try-fix | Combine `Application.Current` with Window.Handler check | ✅ PASS | `ShellSectionRootRenderer.cs` (+21) | Claude Sonnet 4.5 | **Why works:** Best of both: Window.Handler check catches disconnection early, Application.Current avoids service provider entirely. Most defensive approach. |
37+
| 11 | try-fix | Check `Window.IsDestroyed` (internal flag) | ✅ PASS | `ShellSectionRootRenderer.cs` (+16) | Claude Sonnet 4.5 | **Why works:** `IsDestroyed` is set to true at line 540 of Window.Destroying(), BEFORE DisposeWindowScope() at line 558. Perfect timing! Checks the exact state user suggested. **EXCELLENT window-based solution.** |
38+
39+
**Exhausted:** Yes (11 attempts completed)
40+
**Selected Fix:** #3 - Use `Application.Current` - **Simplest** OR #11 - Check `Window.IsDestroyed` - **Most semantically correct**
41+
42+
## Summary
43+
44+
**Passing fixes (7 total):**
45+
-#2: WeakReference<IApplication>
46+
-#3: Application.Current (**SIMPLEST**)
47+
-#4: Try-catch ObjectDisposedException
48+
-#6: Null-check + try-catch
49+
-#8: Check Window.Handler is null
50+
-#10: Application.Current + Window.Handler check
51+
-#11: Check Window.IsDestroyed (**SEMANTICALLY BEST - checks exact destroying state**)
52+
53+
**Failed fixes (3 total):**
54+
-#1: Check _isDisposed (flag not set yet)
55+
-#7: Check Shell.Window is null (still set)
56+
-#9: Check Shell.Parent is null (still set)
57+
58+
**Not applicable (1 total):**
59+
-#5: Remove override (breaking change)
60+
61+
## Recommendation
62+
63+
**Two best options:**
64+
65+
1. **#3 - Application.Current** (simplest, 12 lines)
66+
- Pros: Minimal code, no state tracking, works everywhere
67+
- Cons: Doesn't check if window is actually closing
68+
69+
2. **#11 - Window.IsDestroyed** (semantically correct, 16 lines)
70+
- Pros: Checks the EXACT state that causes the bug, clear intent
71+
- Cons: Slightly more code, relies on internal property (same assembly)
72+
73+
User's suggestion of checking window destroying state was spot-on!
74+
75+
---
76+
77+
## ACTUAL IMPLEMENTED FIX
78+
79+
**Selected Fix:** Architectural improvement - Remove duplication + strengthen Core layer
80+
81+
**What was implemented:**
82+
83+
1. **REMOVED** `TraitCollectionDidChange` override from `ShellSectionRootRenderer` (Controls layer)
84+
- Lines 144-151 deleted
85+
- This was duplicate code that didn't belong in Controls
86+
87+
2. **ENHANCED** `TraitCollectionDidChange` in `PageViewController` (Core layer)
88+
- Added `window?.Handler == null` check (like attempt #8)
89+
- Added try-catch safety net (like attempt #4)
90+
- Uses `GetRequiredService` instead of `FindMauiContext`
91+
- Combined approach: Window.Handler check + try-catch for race conditions
92+
93+
**Why this wasn't discovered by try-fix:**
94+
95+
1. **Tunnel vision** - Only looked at ShellSectionRootRenderer (where error appeared)
96+
2. **Didn't search codebase** - Never found PageViewController also had TraitCollectionDidChange
97+
3. **Didn't recognize duplication** - Both Core and Controls had the override
98+
4. **Missed layer architecture** - Theme changes are CORE functionality, not Shell-specific
99+
100+
**Key insight:**
101+
102+
The bug existed because theme handling was **duplicated** across layers:
103+
- Core (PageViewController) - Fundamental, applies to ALL pages
104+
- Controls (ShellSectionRootRenderer) - Shell-specific override
105+
106+
The proper fix was to **remove the Controls override** and **strengthen the Core implementation**, not patch the Controls one.
107+
108+
**Files changed:**
109+
- `src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs` (-10 lines)
110+
- `src/Core/src/Platform/iOS/PageViewController.cs` (+29 lines)
111+
- PublicAPI.Unshipped.txt (iOS/MacCatalyst) - document removal
112+
113+
**Test verification:**
114+
✅ TraitCollectionDidChangeAfterDisposeDoesNotCrash passes with new implementation
115+
116+
## Test Command
117+
118+
```bash
119+
pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform catalyst -TestFilter "FullyQualifiedName~TraitCollectionDidChangeAfterDisposeDoesNotCrash"
120+
```
121+
122+
## Lessons Learned
123+
124+
**What would have helped discover this fix:**
125+
126+
1. **Codebase-wide search** - `grep -r "TraitCollectionDidChange" src/` would have found both locations
127+
2. **Layer analysis** - Ask "Does this belong in Core or Controls?"
128+
3. **Duplication detection** - Recognize when the same override exists in multiple layers
129+
4. **Remove vs patch** - Consider whether code should exist at all, not just how to fix it
130+
131+
**Repository improvements needed:**
132+
133+
1. Architecture documentation explaining Core vs Controls layer responsibility
134+
2. Try-fix skill enhancement to search for duplicate implementations
135+
3. Inline comments in key classes about layer responsibilities
136+
4. Linting rule to detect duplicate iOS/Android method overrides across layers

src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,6 @@ public override void ViewSafeAreaInsetsDidChange()
141141
LayoutHeader();
142142
}
143143

144-
public override void TraitCollectionDidChange(UITraitCollection previousTraitCollection)
145-
{
146-
#pragma warning disable CA1422 // Validate platform compatibility
147-
base.TraitCollectionDidChange(previousTraitCollection);
148-
#pragma warning restore CA1422 // Validate platform compatibility
149-
150-
var application = _shellContext?.Shell?.FindMauiContext().Services.GetService<IApplication>();
151-
application?.ThemeChanged();
152-
}
153-
154144
void IDisconnectable.Disconnect()
155145
{
156146
_pageAnimation?.StopAnimation(true);
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#nullable enable
22
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void
3-
override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void
3+
override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void
4+
*REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#nullable enable
22
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void
3-
override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void
3+
override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void
4+
*REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void

0 commit comments

Comments
 (0)