Skip to content

Commit 8498a4b

Browse files
RedthCopilot
andcommitted
Address API and code review on the modal navigation seam
API review and code review found blocking issues in the seam. This applies all of them. Recursion (blocking): IModalNavigationHost.IsModalReady folded IModalNavigationPlatform.IsReady back in, so the natural implementation `IsReady => host.IsModalReady && ...` recursed into an uncatchable StackOverflowException. Replaced with IsWindowReady, which is framework readiness only (window handler + page handler) and is documented as safe to consult from IsReady. Threading (blocking): IModalNavigationHost.RequestSync documented itself as callable from any thread but ran readiness checks, page lifecycle events and platform presentation on the calling thread. It now marshals the whole reconciliation entry through the window scope's IDispatcher when a dispatch is required, and the remaining synchronous reentrancy on the UI thread is documented. Failure semantics: a faulting custom PopModalAsync removed the page from PlatformModalStack and never restored it, so a still-visible native modal became absent from both stacks and unreachable. Push and pop now follow one rule: the requested stack is intent, PlatformModalStack is reality, and a faulted operation restores reality to "did not take effect" so the next reconciliation pass retries. Push fault removes, pop fault restores. Deferred pop animation: the pop request leaves the logical stack before the deferred reconciliation runs, so the reconciled pop always got animated:false and the caller's flag was silently dropped. Pending pop animation metadata is now retained until the platform actually dismisses the modal. Deferred completion: the docs promised faults are rethrown to the navigation caller, but a deferred operation completes that caller before the platform runs and the later sync is fire-and-forget. Rather than change when navigation completes, the contract now states exactly when a fault reaches the caller (inline application) and when it is logged instead (deferred). Lifecycle: teardown is now terminal for the override so it cannot be lazily recreated or leaked after Window.Destroying; only attaching a new handler, which brings a new service scope, re-enables resolution. A throwing factory is logged and falls back to the built-in platform permanently instead of escaping from an arbitrary call site or retrying on every access. PageAttached is now keyed on the page handler instance, which fixes a real doubling: the page-change sync can resolve the override before PageAttachedHandler runs. Also documents that teardown does not pop still-presented modals and Dispose must not assume MauiContext is usable, that native dismissals must be routed back through Navigation.PopModalAsync and PopModalAsync must be idempotent, and that IsBatchPopping is a Shell-only optimization hint. Adds IsBatchPushing for symmetry. The factory example no longer uses IPlatformViewHandler, which external backends cannot compile against, and uses the public TFM-neutral DisconnectHandlers instead. Adds regressions for recursion avoidance, background-thread RequestSync marshalling, inline RequestSync, push and pop fault rollback, fault recovery by reconciliation, deferred pop animation in both directions, platform dismissal round-trip, throwing factory, no resurrection after destroy, recreation on new handler, teardown with presented modals, and late resolution delivering PageAttached exactly once. Each was verified to fail against a mutation of the corresponding fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 881947b commit 8498a4b

13 files changed

Lines changed: 922 additions & 75 deletions

File tree

src/Controls/src/Core/Platform/ModalNavigationManager/IModalNavigationHost.cs

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ namespace Microsoft.Maui.Controls.Platform
2222
/// reconciliation loop that keeps the platform stack in sync with the requested stack. A platform
2323
/// implementation is only responsible for the visual presentation of a single push or pop.
2424
/// </para>
25+
/// <para>
26+
/// Every member except <see cref="RequestSync"/> must be read on the UI thread.
27+
/// </para>
2528
/// </remarks>
2629
public interface IModalNavigationHost
2730
{
@@ -33,6 +36,12 @@ public interface IModalNavigationHost
3336
/// <summary>
3437
/// Gets the <see cref="IMauiContext"/> scoped to <see cref="Window"/>.
3538
/// </summary>
39+
/// <remarks>
40+
/// This context is tied to the window's current handler. It is not guaranteed to be usable once
41+
/// the window has been torn down, so do not read it from
42+
/// <see cref="System.IDisposable.Dispose"/>. Capture whatever cleanup needs while the platform
43+
/// instance is still live.
44+
/// </remarks>
3645
/// <exception cref="System.InvalidOperationException">
3746
/// Thrown when the window does not currently have a handler and therefore has no context.
3847
/// </exception>
@@ -67,29 +76,73 @@ public interface IModalNavigationHost
6776
Page CurrentPlatformPage { get; }
6877

6978
/// <summary>
70-
/// Gets a value indicating whether the framework considers the window ready to present modals
71-
/// (the window and its page both have handlers, and <see cref="IModalNavigationPlatform.IsReady"/>
72-
/// returned <see langword="true"/>).
79+
/// Gets a value indicating whether the framework side of the window is ready for modal
80+
/// presentation: the window and its page both have handlers.
7381
/// </summary>
74-
bool IsModalReady { get; }
82+
/// <remarks>
83+
/// <para>
84+
/// This intentionally does <b>not</b> include
85+
/// <see cref="IModalNavigationPlatform.IsReady"/>. Implementations are expected to combine it
86+
/// with their own conditions, for example
87+
/// <c>public bool IsReady =&gt; _host.IsWindowReady &amp;&amp; _nativeWindowIsRealized;</c>.
88+
/// Because the framework folds <see cref="IModalNavigationPlatform.IsReady"/> into its own
89+
/// overall readiness separately, a host property that already included it would recurse into an
90+
/// uncatchable <see cref="System.StackOverflowException"/>.
91+
/// </para>
92+
/// <para>
93+
/// Consulting this from <see cref="IModalNavigationPlatform.IsReady"/> is optional. The framework
94+
/// never calls <see cref="IModalNavigationPlatform.PushModalAsync(Page, bool)"/> or
95+
/// <see cref="IModalNavigationPlatform.PopModalAsync(Page, bool)"/> unless this is already
96+
/// <see langword="true"/>.
97+
/// </para>
98+
/// </remarks>
99+
bool IsWindowReady { get; }
75100

76101
/// <summary>
77-
/// Gets a value indicating whether several modals are being dismissed as a single batch, for
78-
/// example during a <see cref="Shell"/> pop-to-root. Implementations can use this to dismiss
79-
/// without animation or intermediate layout so that the modals in between do not flash on screen.
102+
/// Gets a hint indicating that several modals are being dismissed as a single batch, for example
103+
/// during a <see cref="Shell"/> pop-to-root.
80104
/// </summary>
105+
/// <remarks>
106+
/// This is an optional optimization hint, not a contract. Implementations can use it to dismiss
107+
/// without animation so the modals in between do not flash on screen. It is only ever
108+
/// <see langword="true"/> while <see cref="Controls.Window.Page"/> is a <see cref="Shell"/> that
109+
/// is popping its modal stack; other batch dismissals report <see langword="false"/>. An
110+
/// implementation that ignores it stays correct.
111+
/// </remarks>
81112
bool IsBatchPopping { get; }
82113

114+
/// <summary>
115+
/// Gets a hint indicating that several modals are being presented as a single batch, for example
116+
/// while a <see cref="Shell"/> applies a navigation state that contains multiple modals.
117+
/// </summary>
118+
/// <remarks>
119+
/// The counterpart to <see cref="IsBatchPopping"/>, with the same caveats: it is an optional
120+
/// optimization hint that is only ever <see langword="true"/> for <see cref="Shell"/>, and an
121+
/// implementation that ignores it stays correct.
122+
/// </remarks>
123+
bool IsBatchPushing { get; }
124+
83125
/// <summary>
84126
/// Asks the framework to re-run the reconciliation loop that compares the requested modal stack
85127
/// with <see cref="PlatformModalStack"/> and issues any push or pop that is still outstanding.
86128
/// </summary>
87129
/// <remarks>
130+
/// <para>
88131
/// Call this when <see cref="IModalNavigationPlatform.IsReady"/> transitions from
89132
/// <see langword="false"/> to <see langword="true"/>. The framework does not poll
90133
/// <see cref="IModalNavigationPlatform.IsReady"/>, so a platform that defers readiness must
91134
/// notify the framework through this method or queued modals will never be presented.
92-
/// The call is safe to make from any thread and does not block.
135+
/// </para>
136+
/// <para>
137+
/// This is safe to call from any thread: when the caller is not already on the window's UI
138+
/// thread the work is marshalled there and this method returns immediately. When called
139+
/// <b>on</b> the UI thread the reconciliation starts synchronously, which means
140+
/// <see cref="IModalNavigationPlatform.IsReady"/> and possibly
141+
/// <see cref="IModalNavigationPlatform.PushModalAsync(Page, bool)"/> or
142+
/// <see cref="IModalNavigationPlatform.PopModalAsync(Page, bool)"/> can be re-entered before
143+
/// this method returns. Do not call it while holding a lock, and finish mutating your own state
144+
/// before calling it.
145+
/// </para>
93146
/// </remarks>
94147
void RequestSync();
95148
}

src/Controls/src/Core/Platform/ModalNavigationManager/IModalNavigationPlatform.cs

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,54 @@ namespace Microsoft.Maui.Controls.Platform
2424
/// <para>
2525
/// All members are invoked on the UI thread.
2626
/// </para>
27+
/// <para>
28+
/// <b>Dismissals that start on the platform.</b> When the user dismisses a modal natively — an
29+
/// interactive swipe-to-dismiss, a hardware back press, a native close button — the framework does
30+
/// not observe it. The implementation must route it back through
31+
/// <c>host.Window.Navigation.PopModalAsync()</c> so the cross-platform modal stack and the page
32+
/// lifecycle events stay in sync. The framework then calls
33+
/// <see cref="PopModalAsync(Page, bool)"/> for that page as part of applying the pop, so
34+
/// <see cref="PopModalAsync(Page, bool)"/> must be idempotent and complete successfully when the
35+
/// native modal is already gone.
36+
/// </para>
37+
/// <para>
38+
/// <b>Disposal.</b> <see cref="IDisposable.Dispose"/> is called when the window is destroyed and
39+
/// when the window's handler changes. The framework does <b>not</b> call
40+
/// <see cref="PopModalAsync(Page, bool)"/> for modals that are still presented at that point, so
41+
/// <see cref="IDisposable.Dispose"/> is solely responsible for dismissing and releasing every
42+
/// native modal the backend still owns. Do not assume
43+
/// <see cref="IModalNavigationHost.MauiContext"/>, the window handler, or any platform view
44+
/// obtained through them is still usable there — teardown may already have disposed the window's
45+
/// service scope. Capture whatever cleanup needs while the instance is live and treat disposal as
46+
/// best effort. A disposed instance is never reused; a new one is created from the factory if the
47+
/// window gets a new handler.
48+
/// </para>
2749
/// </remarks>
2850
public interface IModalNavigationPlatform : IDisposable
2951
{
3052
/// <summary>
3153
/// Gets a value indicating whether the backend can present or dismiss a modal right now.
3254
/// </summary>
3355
/// <remarks>
56+
/// <para>
3457
/// While this returns <see langword="false"/> the framework still records pushes and pops on the
3558
/// cross-platform modal stack and still raises the corresponding page lifecycle events, but it
3659
/// does not call <see cref="PushModalAsync(Page, bool)"/> or <see cref="PopModalAsync(Page, bool)"/>.
3760
/// When the backend becomes ready it must call
3861
/// <see cref="IModalNavigationHost.RequestSync"/> so the queued operations are applied.
3962
/// Return <see langword="true"/> unconditionally if the backend has no readiness requirement.
63+
/// </para>
64+
/// <para>
65+
/// Deferring has an observable consequence for callers: <c>Navigation.PushModalAsync</c> and
66+
/// <c>Navigation.PopModalAsync</c> complete as soon as the framework has updated its own state,
67+
/// so they do not wait for the deferred presentation and cannot report a failure in it. See the
68+
/// remarks on <see cref="PushModalAsync(Page, bool)"/> for how deferred faults are reported.
69+
/// </para>
70+
/// <para>
71+
/// Implementations may consult <see cref="IModalNavigationHost.IsWindowReady"/> here. Do not try
72+
/// to derive this from the framework's overall modal readiness, which already folds this
73+
/// property in.
74+
/// </para>
4075
/// </remarks>
4176
bool IsReady { get; }
4277

@@ -53,9 +88,22 @@ public interface IModalNavigationPlatform : IDisposable
5388
/// subsequent pop to race the presentation.
5489
/// </returns>
5590
/// <remarks>
56-
/// If this task faults, the framework removes <paramref name="modal"/> from
57-
/// <see cref="IModalNavigationHost.PlatformModalStack"/> again and rethrows to the caller of
58-
/// <c>PushModalAsync</c>.
91+
/// <para>
92+
/// If this task faults the framework removes <paramref name="modal"/> from
93+
/// <see cref="IModalNavigationHost.PlatformModalStack"/> again, because the modal is not on
94+
/// screen. The cross-platform modal stack is left untouched, so
95+
/// <c>Navigation.ModalStack</c> still contains the page and the next reconciliation pass retries
96+
/// the presentation.
97+
/// </para>
98+
/// <para>
99+
/// Where the fault surfaces depends on when the operation is applied. When
100+
/// <see cref="IsReady"/> was <see langword="true"/> at request time the push is applied inline
101+
/// and the fault is rethrown to the caller of <c>Navigation.PushModalAsync</c>. When the push was
102+
/// deferred because <see cref="IsReady"/> was <see langword="false"/>, that caller has already
103+
/// completed, so the fault is instead logged through the window's
104+
/// <see cref="Microsoft.Extensions.Logging.ILogger"/> and cannot be observed by navigation code.
105+
/// Backends that need deferred failures to be actionable should surface them themselves.
106+
/// </para>
59107
/// </remarks>
60108
Task PushModalAsync(Page modal, bool animated);
61109

@@ -67,23 +115,49 @@ public interface IModalNavigationPlatform : IDisposable
67115
/// <see cref="IModalNavigationHost.PlatformModalStack"/> when this method is called, so
68116
/// <see cref="IModalNavigationHost.CurrentPlatformPage"/> already refers to the page that is
69117
/// about to be revealed.</param>
70-
/// <param name="animated"><see langword="true"/> to animate the transition. The framework passes
71-
/// the value that was supplied to the matching push when the pop is the result of stack
72-
/// reconciliation, and the value supplied to <c>PopModalAsync</c> otherwise.</param>
118+
/// <param name="animated"><see langword="true"/> to animate the transition. The framework preserves
119+
/// the value supplied to <c>Navigation.PopModalAsync</c>, including when the dismissal has to be
120+
/// deferred until <see cref="IsReady"/> becomes <see langword="true"/>. For a pop that comes from
121+
/// reconciling a stack the app changed wholesale (for example a <see cref="Shell"/> pop-to-root),
122+
/// the value recorded for the matching push is used.</param>
73123
/// <returns>A task that completes once the modal is off screen.</returns>
74124
/// <remarks>
125+
/// <para>
75126
/// Implementations are responsible for releasing the platform views created for
76127
/// <paramref name="modal"/>. The framework detaches the page from its parent after this task
77128
/// completes.
129+
/// </para>
130+
/// <para>
131+
/// This must be idempotent. It is called for a page the backend already dismissed natively when
132+
/// that dismissal is routed back through <c>Navigation.PopModalAsync</c>, and it must complete
133+
/// successfully rather than throw in that case.
134+
/// </para>
135+
/// <para>
136+
/// If this task faults the framework puts <paramref name="modal"/> back on
137+
/// <see cref="IModalNavigationHost.PlatformModalStack"/>, because a failed dismissal means the
138+
/// modal is presumed to still be on screen and dropping it would leave a visible modal that no
139+
/// stack knows about. The cross-platform modal stack is left without the page, so the next
140+
/// reconciliation pass retries the dismissal. As with
141+
/// <see cref="PushModalAsync(Page, bool)"/>, the fault is rethrown to the caller of
142+
/// <c>Navigation.PopModalAsync</c> only when the pop was applied inline; a deferred pop that
143+
/// faults is logged instead.
144+
/// </para>
78145
/// </remarks>
79146
Task PopModalAsync(Page modal, bool animated);
80147

81148
/// <summary>
82149
/// Called when the window's page gets a handler, including when the page is replaced.
83150
/// </summary>
84151
/// <remarks>
152+
/// <para>
85153
/// Use this to attach platform hooks that depend on the window's content being realized, such as
86-
/// a hardware back button handler. This may be called multiple times for the same window.
154+
/// a hardware back button handler. This may be called multiple times for the same window. It is
155+
/// also called once immediately after the instance is created when the window's page already has
156+
/// a handler, so a backend that resolves late does not miss the notification.
157+
/// </para>
158+
/// <para>
159+
/// The framework guarantees it is never delivered twice for the same page-handler attachment.
160+
/// </para>
87161
/// </remarks>
88162
void PageAttached();
89163
}

src/Controls/src/Core/Platform/ModalNavigationManager/IModalNavigationPlatformFactory.cs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,19 @@ namespace Microsoft.Maui.Controls.Platform
1919
/// lifecycle events and the push/pop reconciliation loop.
2020
/// </para>
2121
/// <para>
22-
/// The factory itself is resolved from the window's service scope, so it may be registered with any
23-
/// lifetime. The framework always calls it once per window and disposes the returned instance, so
24-
/// return a new <see cref="IModalNavigationPlatform"/> for each call.
22+
/// The factory is resolved from the window's service scope, so it may be registered with any
23+
/// lifetime. The framework calls it once per window and disposes the returned instance, so return a
24+
/// new <see cref="IModalNavigationPlatform"/> for each call. It is called again for the same window
25+
/// only if the window gets a new handler, which brings a new service scope (for example an Android
26+
/// activity recreation).
27+
/// </para>
28+
/// <para>
29+
/// If this method throws, the framework logs the exception and permanently falls back to the
30+
/// built-in platform for that window rather than rethrowing from whatever call site happened to
31+
/// trigger resolution. Creation is not retried, so a failed registration fails deterministically and
32+
/// visibly in the log instead of throwing repeatedly from arbitrary navigation code. Prefer
33+
/// returning <see langword="null"/> over throwing when a backend deliberately does not want to
34+
/// handle a window.
2535
/// </para>
2636
/// <example>
2737
/// Registering a backend that presents modals with its own native window stack:
@@ -35,30 +45,44 @@ namespace Microsoft.Maui.Controls.Platform
3545
/// public sealed class MyModalNavigationPlatform : IModalNavigationPlatform
3646
/// {
3747
/// readonly IModalNavigationHost _host;
48+
/// readonly MyNativeModalStack _stack;
3849
///
39-
/// public MyModalNavigationPlatform(IModalNavigationHost host) =&gt; _host = host;
50+
/// public MyModalNavigationPlatform(IModalNavigationHost host)
51+
/// {
52+
/// _host = host;
53+
/// // Captured while the instance is live: Dispose must not depend on the MauiContext.
54+
/// _stack = MyNativeModalStack.For(host.MauiContext);
55+
/// }
4056
///
41-
/// public bool IsReady =&gt; true;
57+
/// public bool IsReady =&gt; _host.IsWindowReady &amp;&amp; _stack.IsRealized;
4258
///
4359
/// public async Task PushModalAsync(Page modal, bool animated)
4460
/// {
4561
/// var nativeView = modal.ToPlatform(_host.MauiContext);
46-
/// await MyNativeModalStack.For(_host.Window).PushAsync(nativeView, animated);
62+
/// await _stack.PushAsync(nativeView, animated &amp;&amp; !_host.IsBatchPushing);
4763
/// }
4864
///
4965
/// public async Task PopModalAsync(Page modal, bool animated)
5066
/// {
51-
/// await MyNativeModalStack.For(_host.Window).PopAsync(animated &amp;&amp; !_host.IsBatchPopping);
52-
/// (modal.Handler as IPlatformViewHandler)?.Dispose();
67+
/// // Idempotent: the modal may already be gone when the user dismissed it natively.
68+
/// if (_stack.Contains(modal))
69+
/// await _stack.PopAsync(animated &amp;&amp; !_host.IsBatchPopping);
70+
///
71+
/// modal.DisconnectHandlers();
5372
/// }
5473
///
55-
/// public void PageAttached()
74+
/// public void PageAttached() =&gt;
75+
/// _stack.BackButtonPressed = () =&gt; _host.CurrentPage?.SendBackButtonPressed() ?? false;
76+
///
77+
/// // A native dismissal the framework can't see has to be routed back through Navigation so the
78+
/// // cross-platform stack and the page lifecycle events stay in sync.
79+
/// void OnDismissedNatively() =&gt; _host.Window.Navigation.PopModalAsync(animated: false);
80+
///
81+
/// public void Dispose()
5682
/// {
57-
/// MyNativeWindow.For(_host.Window).BackButtonPressed =
58-
/// () =&gt; _host.CurrentPage?.SendBackButtonPressed() ?? false;
83+
/// // Still-presented modals are NOT popped by the framework during teardown.
84+
/// _stack.DismissAll();
5985
/// }
60-
///
61-
/// public void Dispose() =&gt; MyNativeModalStack.Release(_host.Window);
6286
/// }
6387
///
6488
/// // In MauiProgram:

0 commit comments

Comments
 (0)