Skip to content

Add delegate-based alert dialog extensibility convention (no public API changes)#35095

Merged
kubaflo merged 8 commits intoinflight/currentfrom
redth/public-alert-dialog-extensibility
Apr 30, 2026
Merged

Add delegate-based alert dialog extensibility convention (no public API changes)#35095
kubaflo merged 8 commits intoinflight/currentfrom
redth/public-alert-dialog-extensibility

Conversation

@Redth
Copy link
Copy Markdown
Member

@Redth Redth commented Apr 22, 2026

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 from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description of Change

Enables third-party platform backends (for example Maui.Gtk) to provide their own DisplayAlertAsync / DisplayActionSheetAsync / DisplayPromptAsync implementations without MAUI exposing any new public API. This complements #33267: because that PR introduces a new public interface, it must wait for .NET 11. This PR solves the same problem using already-shipped public argument types, so it is shippable in .NET 10.

AlertManager.Subscribe() now keeps the existing explicit internal IAlertManagerSubscription path, then checks for keyed delegate registrations before falling back to the platform default. The delegate signatures use only existing public types:

Func<Page, AlertArguments,       Task<bool>>
Func<Page, ActionSheetArguments, Task<string>>
Func<Page, PromptArguments,      Task<string>>

The registrations are keyed so MAUI does not accidentally consume unrelated Func<> services:

Dialog Service key
Alert Microsoft.Maui.Controls.DisplayAlert
Action sheet Microsoft.Maui.Controls.DisplayActionSheet
Prompt Microsoft.Maui.Controls.DisplayPrompt

Consumer usage:

builder.Services.AddKeyedSingleton<Func<Page, AlertArguments, Task<bool>>>(
    "Microsoft.Maui.Controls.DisplayAlert",
    async (page, args) =>
    {
        return await MyGtkDialog.ShowAsync(args.Title, args.Message, args.Accept, args.Cancel);
    });

If any keyed delegate is registered, MAUI wraps it in a new internal DelegateAlertSubscription. Registered operations dispatch to their delegate; unregistered operations fall through to the platform default. The delegate returns the dialog result and MAUI completes the corresponding AlertArguments, ActionSheetArguments, or PromptArguments internally. If the delegate returns null, faults, or cancels, the caller observes that through the original Display*Async task instead of hanging silently.

Precedence order in Subscribe():

  1. Explicit IAlertManagerSubscription service (existing internal path, unchanged)
  2. Keyed result-returning delegate convention (new)
  3. Platform default subscription (existing fallback)

Notes for reviewers

  • Zero public API surface: no PublicAPI.*.txt changes. The new DelegateAlertSubscription class and key constants are internal; consumers use literal string keys documented on the existing argument types.
  • Keyed registrations are intentional. Unkeyed Func<> services are ignored to avoid accidental collisions.
  • The delegate returns the dialog result instead of calling args.SetResult(...). This matches the built-in async platform pattern where MAUI completes the argument after awaiting the native dialog result.
  • Per-operation fall-through is intentional. A backend can override just alerts and keep the platform action sheet/prompt, for example.
  • OnPageBusy is excluded from the convention (obsolete in .NET 10, removed in .NET 11) and always routes to the fallback.
  • When .NET 11 ships a proper public IAlertManager/IAlertDialogProvider, this delegate convention can stay as a lightweight alias or be deprecated. Either way, .NET 10 consumers are unblocked now.

Tests

Unit coverage in AlertManagerTests.cs includes:

  • Alert, action sheet, and prompt delegate dispatch
  • Returned delegate results completing the original Display*Async caller
  • Unregistered operation fall-through
  • Unkeyed delegate services being ignored
  • Explicit IAlertManagerSubscription precedence
  • Synchronous and asynchronous delegate faults
  • Delegate cancellation forwarding
  • Null task contract violation

Focused AlertManagerTests pass locally: 21/21.

Issues Fixed

Fixes #34104

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35095

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35095"

@PureWeen
Copy link
Copy Markdown
Member

🔍 Multi-Model Code Review — PR #35095

PR: Add delegate-based alert dialog extensibility convention (no public API changes)
Author: @Redth | Base: main | Files changed: 6 (+277 / −3)
CI Status: ⏳ Pending (builds still running at time of review)
Prior reviews: None


🟡 MODERATE

1. Null Task from delegate causes silent permanent hang (3/3 reviewers)

  • File: DelegateAlertSubscription.cs, lines 91–93
  • What: If a delegate returns null instead of a Task, Invoke() returns immediately without completing the TaskCompletionSource<T>. The caller's await DisplayAlertAsync(...) hangs forever — no exception, no timeout, no log.
  • Why it matters: This is an extensibility point for third-party backends. A non-async method returning null is a common mistake. Silent hangs are extremely difficult to diagnose.
  • Suggested fix: After if (task is null), call completion.TrySetException(new InvalidOperationException("Alert delegate returned a null Task.")) or completion.TrySetCanceled().
  • Test exists: ❌ No

2. Delegate that returns successfully without calling SetResult also hangs (2/3 reviewers)

  • File: DelegateAlertSubscription.cs, ForwardFaultOrCancellation (lines 116–127)
  • What: If a delegate returns Task.CompletedTask but forgets to call args.SetResult(...), ForwardFaultOrCancellation is a no-op (task succeeded) and the TCS is never completed. Same silent hang as [Draft] Readme WIP #1.
  • Why it matters: The contract says delegates must call SetResult, but there's no defensive check. Consider logging a warning or completing the TCS with a default if the task completes and the TCS remains incomplete.
  • Test exists: ❌ No

3. Missing test coverage for ActionSheet and Prompt delegate paths (2/3 reviewers)

  • File: AlertManagerTests.cs
  • What: All 4 new tests exercise only Func<Page, AlertArguments, Task>. The ActionSheetArguments and PromptArguments delegate paths through DelegateAlertSubscription are never tested end-to-end.
  • Why it matters: A regression in those paths (e.g., wrong delegate resolved, wrong TCS passed) would go undetected.
  • Test exists: ❌ No

4. DI type collision risk with Func<> service keys (3/3 reviewers after adversarial round)

  • File: AlertManager.cs, TryCreateDelegateSubscription
  • What: Func<Page, AlertArguments, Task> is a concrete closed generic of a BCL type. Any other library or user code that registers the same Func<> signature for an unrelated purpose would be silently consumed as an alert handler.
  • Why it matters: Unlike a dedicated interface, Func<> types have no namespace scoping. Low probability but non-zero risk.
  • Note: This appears to be a deliberate design trade-off (avoiding new public API). Recommend at minimum clear documentation warning consumers about this constraint.

🟢 MINOR

5. No test for asynchronous fault propagation (3/3 reviewers)

  • File: AlertManagerTests.cs
  • What: DelegateFuncExceptionPropagatesToCaller only covers a synchronous throw. The ContinueWithForwardFaultOrCancellation path (when the returned Task itself faults asynchronously) is untested.
  • Test exists: ❌ No

6. No test for cancellation forwarding (3/3 reviewers)

  • File: DelegateAlertSubscription.cs, line 126 (TrySetCanceled)
  • What: The task.IsCanceled branch in ForwardFaultOrCancellation has no test. A delegate returning a canceled task should propagate OperationCanceledException.
  • Test exists: ❌ No

7. Timing-dependent fall-through test assertion (2/3 reviewers)

  • File: AlertManagerTests.cs, DelegateFuncFallsThroughForUnregisteredOperation
  • What: Task.WhenAny(promptTask, Task.Delay(100)) is a wall-clock race. On a heavily loaded CI agent, 100ms may not be enough and could produce a false negative.
  • Suggestion: Use a deterministic approach — e.g., check promptTask.IsCompleted == false or verify the fallback's OnPromptRequested was called via NSubstitute.

8. Unawaited Task in ExplicitSubscriptionWinsOverDelegateFuncs (3/3 reviewers after adversarial round)

  • File: AlertManagerTests.cs, ExplicitSubscriptionWinsOverDelegateFuncs
  • What: page.DisplayAlertAsync(...) is fire-and-forget. If the method threw due to a future bug, the exception would be silently swallowed in the test.
  • Suggestion: Either await it or explicitly discard with _ =.

9. Fallback subscription eagerly allocated when all delegates registered (2/3 reviewers)

  • File: AlertManager.cs, TryCreateDelegateSubscriptionCreateSubscription(context)
  • What: CreateSubscription(context) is called unconditionally, even when all three delegates are present and the fallback would only be used for the obsolete OnPageBusy. On Android, this instantiates AlertRequestHelper(Activity, IMauiContext), holding an Activity reference unnecessarily.
  • Note: Acceptable since OnPageBusy is removed in .NET 11. Low impact.

10. Redundant null check on task.Exception (3/3 reviewers after adversarial round)

  • File: DelegateAlertSubscription.cs, line 121
  • What: When task.IsFaulted is true, task.Exception is guaranteed non-null by the TPL. The is not null check is harmless but unnecessary.

Discarded Findings

Finding Flagged by Adversarial result Outcome
ExecuteSynchronously + TaskScheduler.Default contradiction 1/3 Both others disagreed — this is a well-known TPL pattern ❌ Discarded

Thread Safety ✅

No thread-safety issues introduced. DelegateAlertSubscription is effectively immutable (all readonly fields). TrySetResult/TrySetException/TrySetCanceled are thread-safe. The pre-existing _subscription field lacks synchronization, but this PR does not worsen that.

Test Coverage Assessment

The 4 new tests cover the happy path well for alerts, but leave significant gaps:

  • ❌ ActionSheet delegate path (not tested)
  • ❌ Prompt delegate path (not tested)
  • ❌ Async fault propagation via ContinueWith (not tested)
  • ❌ Task cancellation forwarding (not tested)
  • ❌ Null task return (not tested)
  • ❌ Delegate that forgets SetResult (not tested)

Recommendation: ⚠️ Request Changes

Required before merge:

  1. Fix [Draft] Readme WIP #1 — Handle null Task return defensively (complete the TCS with an error instead of silently returning)
  2. Fix Third #3 — Add at least one test each for the ActionSheet and Prompt delegate paths

Strongly recommended:
3. Add tests for async fault propagation (#5) and cancellation forwarding (#6)
4. Make the fall-through test (#7) deterministic
5. Consider defensive handling for the "forgot SetResult" case (#2) — at least document the contract clearly

@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 23, 2026

Re: multi-model review. Thanks for the careful pass - addressed in aa42106.

Moderate

  1. Null Task hang - Accepted. Invoke now calls TrySetException(new InvalidOperationException("Alert delegate returned a null Task...")) instead of returning silently.
  2. Successful Task without SetResult - Accepted. Renamed ForwardFaultOrCancellation to ForwardCompletion and added an else if (!completion.Task.IsCompleted) branch that surfaces an InvalidOperationException mentioning the missing SetResult call. TrySet* everywhere, so well-behaved delegates that did call SetResult are unaffected.
  3. Missing ActionSheet/Prompt coverage - Accepted. Added DelegateFuncInterceptsDisplayActionSheet and DelegateFuncInterceptsDisplayPrompt.
  4. Func<> collision risk - Partially accepted. Added a <para> to the <remarks> on AlertArguments, ActionSheetArguments, and PromptArguments warning that the closed generic Func is reserved for MAUI alert handling. Kept the design - this is the whole point of shipping the convention without new public surface in .NET 10.

Minor

  1. Async fault - Accepted. Added DelegateFuncAsynchronousFaultPropagatesToCaller (delegate yields, then throws) to exercise the ContinueWith path rather than the synchronous-throw path.
  2. Cancellation - Accepted. Added DelegateFuncCancellationPropagatesToCaller using Task.FromCanceled.
  3. Flaky timing test - Accepted. Rewrote DelegateFuncFallsThroughForUnregisteredOperation to track delegate invocation with a flag instead of racing Task.Delay(100). We now assert the alert delegate fires for an alert request and does NOT fire for a prompt request, with no wall-clock dependency.

Also added DelegateFuncReturningNullTaskFaultsTheCaller and DelegateFuncCompletingWithoutSetResultFaultsTheCaller to lock in the new contract-enforcement behavior. All 20 tests in AlertManagerTests pass locally.

@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 27, 2026

Re: dogfood instructions. Thanks. This is the automated dogfood instructions comment and does not request a code change, so no local changes were needed for this comment.

@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 27, 2026

Re: multi-model review. Thanks again. The earlier reply/commit addressed points 1-7, and I also addressed 8-10 in 2764b77: the explicit-subscription test now awaits completion, delegate fallback creation is lazy, and the redundant task.Exception null check was removed. Focused AlertManagerTests pass: 20/20.

@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 27, 2026

Follow-up for #35095 (comment): thanks, I rechecked the current code and the substantive items are addressed: null/forgotten SetResult now fail fast, ActionSheet/Prompt plus async fault/cancel tests are covered, the fall-through test is deterministic, the explicit subscription task is awaited, fallback creation is lazy, and the redundant null check is gone. For the Func<> collision concern, I kept the keyed-only convention so unkeyed registrations are ignored, and pushed 8ee9d14 to fix the remaining internal comment so it documents AddKeyedSingleton with the MAUI service keys.

@Redth Redth marked this pull request as ready for review April 27, 2026 23:09
Copilot AI review requested due to automatic review settings April 27, 2026 23:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an internal, DI-based extensibility convention to let third-party platform backends intercept DisplayAlert / DisplayActionSheet / DisplayPrompt by registering keyed delegate services, without introducing new public API surface.

Changes:

  • Add keyed-service lookup in AlertManager.Subscribe() to create an internal delegate-backed subscription when any of the dialog delegates are registered.
  • Introduce DelegateAlertSubscription to dispatch dialog requests to delegates and enforce completion contracts (fault/cancel/invalid contract → caller observes it).
  • Add unit tests covering delegate dispatch, precedence, fall-through behavior, and contract violations; update argument type remarks to document the convention.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Controls/src/Core/Platform/AlertManager/AlertManager.cs Adds internal service keys and delegate-convention subscription creation during Subscribe().
src/Controls/src/Core/Platform/AlertManager/DelegateAlertSubscription.cs New internal subscription that forwards to keyed delegates or falls back to platform defaults.
src/Controls/src/Core/AlertArguments.cs Documents keyed delegate convention for alerts.
src/Controls/src/Core/ActionSheetArguments.cs Documents keyed delegate convention for action sheets.
src/Controls/src/Core/PromptArguments.cs Documents keyed delegate convention for prompts.
src/Controls/tests/Core.UnitTests/AlertManagerTests.cs Adds tests validating delegate convention behavior and error/cancellation propagation.

Comment thread src/Controls/src/Core/Platform/AlertManager/DelegateAlertSubscription.cs Outdated
Comment thread src/Controls/src/Core/PromptArguments.cs
Comment thread src/Controls/src/Core/ActionSheetArguments.cs Outdated
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 28, 2026

/review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Expert Code Review completed successfully!

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review — PR #35095

Methodology: 3 independent reviewers with adversarial consensus (disputed findings validated by cross-model follow-up)

Findings

# Severity File Consensus Finding
1 🟡 MODERATE DelegateAlertSubscription.cs:131-134 2/3 reviewers "SetResult before Task completes" contract may surprise third-party implementers. MAUI's own platform AlertRequestHelper uses fire-and-forget (show dialog → return → callback calls SetResult). The new delegate contract requires await-to-completion before returning, which differs from the internal idiom. Well-documented, but discoverability could be improved with a code sample in the XML docs.
2 🟢 MINOR AlertManager.cs:11-13 2/3 reviewers Internal constants form an undocumented string-based contract. Third-party consumers must hardcode string keys with no compile-time validation. Intentional trade-off for zero public API — consider adding a stability comment on the constants.

Discarded Findings (single reviewer only)

The following were flagged by only 1 of 3 reviewers and rejected during adversarial follow-up:

  • _fallback.Value null risk — all platform CreateSubscription implementations always return non-null (confirmed by 2 follow-up reviewers)
  • Lazy<T> exception caching — low practical risk since Subscribe() runs during valid window setup
  • ExecuteSynchronously on ContinueWith — correct for lightweight ForwardCompletion
  • Test coverage gaps (error tests only for AlertArguments) — shared generic code path
  • Test timing assertion on resultTask.IsCompleted — valid given test infrastructure

CI Status

✅ All 39 CI checks passed (build, unit tests, integration tests, device tests).

Test Coverage Assessment

The PR adds 11 new unit tests covering: delegate dispatch (alert, action sheet, prompt), precedence over delegate convention, unkeyed delegate rejection, per-operation fallback, synchronous/asynchronous fault propagation, cancellation forwarding, null task handling, and missing SetResult contract violation. Coverage is thorough for the new code paths.

Verdict: LGTM with suggestions

Confidence: high

The implementation is clean, well-structured, and thoroughly tested. The delegate-based extensibility convention using keyed DI services is a pragmatic solution for .NET 10 without new public API. The two suggestions above (doc sample for the SetResult contract, stability comment on string keys) are non-blocking improvements that would help third-party consumers.

Generated by Expert Code Review for issue #35095 · ● 21.6M

Comment thread src/Controls/src/Core/Platform/AlertManager/AlertManager.cs
@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 28, 2026

/review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Expert Code Review completed successfully!

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review — PR #35095

Methodology: 3 independent reviewers with adversarial consensus (disputed findings validated by cross-reviewer agreement)

Findings

# Severity File Consensus Finding
1 🟢 MINOR DelegateAlertSubscription.cs:95, 128-130 2/3 reviewers Cancellation semantics not fully preserved. Two aspects: (a) TrySetCanceled() without args loses the original CancellationToken from the delegate's canceled task; (b) sync throw OperationCanceledException in the catch block produces a faulted task rather than a canceled one. Low practical impact today since MAUI callers don't inspect the token, but sets imprecise precedent for third-party extensibility. See inline comment for suggested fix.

Discarded Findings (did not reach consensus)

  • Duplicate ExecuteSynchronously parameter — flagged by 1 reviewer from the diff, but verified on disk that the file is correct (single parameter). The GitHub API diff contained a rendering artifact.
  • Internal service key discoverability — flagged by 1 reviewer, but this was addressed in a prior review round (author added stability comment on the constants). Not re-flagged.
  • Error-path test coverage — 1 reviewer noted error-path tests only exercise AlertArguments delegates, not ActionSheetArguments/PromptArguments. Since the Invoke<T> method is generic and shared across all three, the risk of a type-specific regression is very low.

CI Status

⏳ CI checks are currently in progress (build triggered after latest commits at e720f20). Cannot confirm pass/fail at this time.

Test Coverage Assessment

The PR adds 11 new unit tests covering: delegate dispatch for all 3 dialog types, precedence over delegate convention, unkeyed delegate rejection, per-operation fallthrough, synchronous and asynchronous fault propagation, cancellation forwarding, null task contract violation, and result-only completion. Coverage is thorough for the new code paths.

Summary

The implementation is clean, well-structured, and thoroughly tested. The delegate-based extensibility convention using keyed DI services is a pragmatic solution for .NET 10 that avoids new public API surface. The single finding is a non-blocking suggestion to improve cancellation fidelity for third-party consumers. All prior review feedback has been addressed.

Generated by Expert Code Review · 3-model adversarial consensus

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • pkgs.dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "pkgs.dev.azure.com"

See Network Configuration for more information.

Generated by Expert Code Review for issue #35095 · ● 20.1M

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 29, 2026

Multimodal Review — PR #35095

Reviewed: code, related issue (#34104), prior reviewer threads, commit history (8 commits, all addressing reviewer feedback), the alternative public‑API approach (#33267), and the surrounding AlertManager partial‑class topology across all platforms.

Verdict: LGTM with a few low‑priority polish items

This is a clean, pragmatic .NET 10 unblocker for third‑party platform backends (e.g. Maui.Gtk) that today have to use DispatchProxy + reflection on internal types (#34104). The keyed‑DI convention is well chosen — keys are scoped, the Subscribe() precedence is sane, the contract (delegate returns the dialog result, wrapper completes the TCS) eliminates the “forgot to call SetResult” footgun by construction, and the fallback is Lazy so per‑operation pass‑through is cheap.

I’ve verified each of the prior expert‑review fixes landed in 07e38b2:

  • ✅ Cancellation token preservation in both async and sync paths (ForwardCompletion + Invoke<T> catch block).
  • ✅ Stability comment on the three internal *ServiceKey constants.
  • ✅ Generic “dialog delegate” framing of error messages.
  • ✅ Three operation‑specific “intercepts” tests + async fault, cancellation forwarding, null‑Task contract, and unkeyed‑delegate rejection.
  • DelegateFuncFallsThroughForUnregisteredOperation no longer relies on a wall‑clock race.

New observations (not yet raised)

🟢 IKeyedServiceProvider non‑support is a silent no‑op

TryCreateDelegateSubscription does if (services is not IKeyedServiceProvider keyedServices) { return null; }. The default MAUI host (Microsoft.Extensions.DependencyInjection.ServiceProvider) implements IKeyedServiceProvider since .NET 8, so this works for stock apps. But apps using an alternative container (Autofac, Lamar, Grace) where the root provider doesn’t implement that interface will silently get the platform default, even if they registered the keyed delegates. Suggest a single‑shot informational log here so the failure mode is diagnosable:

if (services is not IKeyedServiceProvider keyedServices)
{
    context.CreateLogger<AlertManager>()?.LogDebug(
        "IServiceProvider does not implement IKeyedServiceProvider; the dialog delegate convention is unavailable.");
    return null;
}

🟢 Cancel == null semantics aren’t spelled out for the alert delegate

AlertArguments.Cancel can be null (when the page caller passes cancel: null, e.g. DisplayAlertAsync("T", "M", "OK")). The new XML remarks say:

The delegate should return true when the user accepts the alert, or false when the user cancels or dismisses it.

But the iOS implementation only adds a Cancel button when arguments.Cancel != null (AlertManager.iOS.cs:77), so when Cancel is null the platform path can only return true. A backend author following the docs literally might still expose a Cancel affordance and confuse callers. A one‑line clarification would help — e.g. “If arguments.Cancel is null, the delegate should not present a cancel affordance and should return true on acceptance.”

🟢 [EditorBrowsable(Never)] types are now the de‑facto extensibility surface

AlertArguments, ActionSheetArguments, and PromptArguments are public but [EditorBrowsable(Never)] and explicitly documented as “For internal use only. This API can be changed or removed without notice at any time.” Third‑party backends are now expected to take a hard dependency on them via the delegate signature. This is fine as a .NET 10 expedient, and the new XML remarks soften the “may change without notice” wording, but it’s worth being explicit in those remarks that the shape of these types (Title, Message, Accept, Cancel, Buttons, Destruction, MaxLength, Keyboard, InitialValue, …) must now be treated as a stability surface for the lifetime of this convention. Otherwise these two doc claims contradict each other and a future field rename could break Maui.Gtk silently.

🟢 Tests mock IServiceProvider with NSubstitute; no end‑to‑end ServiceCollection test

All 11 new tests build the SP with Substitute.For<IServiceProvider, IKeyedServiceProvider>() and stub GetKeyedService(...) directly. That validates the wrapper logic but never exercises a real services.AddKeyedSingleton<Func<Page, AlertArguments, Task<bool>>>(AlertManager.DisplayAlertServiceKey, …) → BuildServiceProvider() → Subscribe() round‑trip. One end‑to‑end smoke test would catch:

  • accidental mismatches between the delegate type written in docs vs. resolved (e.g. Task vs Task<bool>)
  • regressions if a future Microsoft.Extensions.DependencyInjection change alters keyed‑Func resolution

This is non‑blocking — the unit coverage of DelegateAlertSubscription itself is good.

🟢 Fallback subscription factory captures IMauiContext by closure

new DelegateAlertSubscription(..., () => CreateSubscription(context)) is fine, but the captured context keeps the entire IMauiContext reachable for the lifetime of the Window. Today that’s already the case via _window.Handler, so this introduces no new leak — just calling it out so future refactors don’t accidentally promote DelegateAlertSubscription past Window lifetime.

🟢 Assert.Contains("null Task", ex.Message, …) couples a test to the message wording

Minor. If the message text is reworded again (it already changed once during this PR), the test breaks despite correct behavior. Consider asserting on type only (Assert.IsType<InvalidOperationException>(ex)).

Forward‑compatibility (#33267 alignment) — looks fine ✅

#33267 (the public IAlertManager interface, draft, planned for .NET 11) operates one level higher in the pipeline (whole subscription) than this convention (per‑operation delegates). They’re complementary, and the precedence in Subscribe() (explicit IAlertManagerSubscription first → keyed delegate → platform default) means a future public IAlertManager registration will continue to win. The PR description’s plan to keep this convention as a lightweight alias once #33267 ships is reasonable.

Test coverage assessment

11 new tests covering: per‑operation interception (alert, action sheet, prompt), explicit subscription precedence, unkeyed delegate rejection, per‑operation fall‑through, sync + async fault propagation, both async‑canceled and sync OperationCanceledException paths, null‑Task contract violation, and result‑without‑SetResult completion. Coverage of the wrapper itself is thorough.

Risk surface

Low. Strictly additive — the existing IAlertManagerSubscription path is unchanged; the delegate convention is gated behind IKeyedServiceProvider + actual delegate registrations, so apps that don’t opt in see zero behavior change.


Posted as a code‑review comment, not an approval — approve/changes‑requested decisions are left to human reviewers.

@kubaflo kubaflo changed the base branch from main to inflight/current April 30, 2026 18:53
Redth and others added 8 commits April 30, 2026 21:56
Third-party platform backends can now provide custom DisplayAlertAsync /
DisplayActionSheetAsync / DisplayPromptAsync implementations without MAUI
exposing any new public API. AlertManager.Subscribe() now looks up three
already-publicly-typed delegates in the service provider:

    Func<Page, AlertArguments, Task>
    Func<Page, ActionSheetArguments, Task>
    Func<Page, PromptArguments, Task>

If any are registered, MAUI wraps them in an internal DelegateAlertSubscription
that invokes the delegate for operations that have one and falls through to the
platform-default subscription for operations that don't. This lets backends
opt in incrementally and avoid the DispatchProxy + reflection workaround
documented in #34104.

Explicitly-registered IAlertManagerSubscription (the existing internal path)
still takes precedence. Delegate exceptions and cancellations are forwarded
to the caller via the argument's TaskCompletionSource.

Adds unit test coverage for interception, fall-through, precedence, and
exception propagation. No changes to PublicAPI.*.txt.

Fixes #34104

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Surface InvalidOperationException via the TaskCompletionSource when an
  alert/action sheet/prompt delegate returns a null Task or completes
  successfully without calling SetResult, instead of silently hanging.
- Use TrySetException so well-behaved delegates that did call SetResult
  are unaffected.
- Add tests for ActionSheet and Prompt delegate paths, asynchronous
  fault propagation, cancellation forwarding, null-Task and
  missing-SetResult contract violations.
- Replace the wall-clock racing fall-through test with a deterministic
  invocation-tracking assertion.
- Document in AlertArguments / ActionSheetArguments / PromptArguments
  remarks that the Func<Page, *Args, Task> service key is reserved for
  MAUI alert handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the delegate alert fallback subscription lazy so platform fallback objects are only created when needed. Await the explicit-subscription precedence test so failures surface through the test task, and simplify fault forwarding by removing the redundant null check after Task.IsFaulted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve alert dialog delegates only from keyed DI registrations so the convention does not consume unrelated Func services. Document the literal keys for alert, action sheet, and prompt handlers, and add coverage that unkeyed delegates are ignored.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update the internal delegate subscription example to show keyed service registrations so it matches the keyed-only convention used by AlertManager.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change the keyed alert dialog delegates to return Task<TResult> so MAUI completes the AlertArguments, ActionSheetArguments, and PromptArguments internally from the returned result. This removes the surprising requirement that third-party implementations call SetResult before their delegate task completes while keeping the convention keyed-only and public-API-free.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kubaflo kubaflo force-pushed the redth/public-alert-dialog-extensibility branch from 07e38b2 to 7475d22 Compare April 30, 2026 20:56
@kubaflo kubaflo merged commit 5e9bf80 into inflight/current Apr 30, 2026
2 of 11 checks passed
@kubaflo kubaflo deleted the redth/public-alert-dialog-extensibility branch April 30, 2026 20:59
@github-actions github-actions Bot added this to the .NET 10 SR7 milestone Apr 30, 2026
github-actions Bot pushed a commit that referenced this pull request May 6, 2026
…PI changes) (#35095)

<!-- 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

Enables third-party platform backends (for example Maui.Gtk) to provide
their own `DisplayAlertAsync` / `DisplayActionSheetAsync` /
`DisplayPromptAsync` implementations without MAUI exposing any new
public API. This complements #33267: because that PR introduces a new
public interface, it must wait for .NET 11. This PR solves the same
problem using already-shipped public argument types, so it is shippable
in .NET 10.

`AlertManager.Subscribe()` now keeps the existing explicit internal
`IAlertManagerSubscription` path, then checks for keyed delegate
registrations before falling back to the platform default. The delegate
signatures use only existing public types:

```csharp
Func<Page, AlertArguments,       Task<bool>>
Func<Page, ActionSheetArguments, Task<string>>
Func<Page, PromptArguments,      Task<string>>
```

The registrations are keyed so MAUI does not accidentally consume
unrelated `Func<>` services:

| Dialog | Service key |
|---|---|
| Alert | `Microsoft.Maui.Controls.DisplayAlert` |
| Action sheet | `Microsoft.Maui.Controls.DisplayActionSheet` |
| Prompt | `Microsoft.Maui.Controls.DisplayPrompt` |

Consumer usage:

```csharp
builder.Services.AddKeyedSingleton<Func<Page, AlertArguments, Task<bool>>>(
    "Microsoft.Maui.Controls.DisplayAlert",
    async (page, args) =>
    {
        return await MyGtkDialog.ShowAsync(args.Title, args.Message, args.Accept, args.Cancel);
    });
```

If any keyed delegate is registered, MAUI wraps it in a new internal
`DelegateAlertSubscription`. Registered operations dispatch to their
delegate; unregistered operations fall through to the platform default.
The delegate returns the dialog result and MAUI completes the
corresponding `AlertArguments`, `ActionSheetArguments`, or
`PromptArguments` internally. If the delegate returns `null`, faults, or
cancels, the caller observes that through the original `Display*Async`
task instead of hanging silently.

Precedence order in `Subscribe()`:

1. Explicit `IAlertManagerSubscription` service (existing internal path,
unchanged)
2. Keyed result-returning delegate convention (new)
3. Platform default subscription (existing fallback)

### Notes for reviewers

- Zero public API surface: no `PublicAPI.*.txt` changes. The new
`DelegateAlertSubscription` class and key constants are internal;
consumers use literal string keys documented on the existing argument
types.
- Keyed registrations are intentional. Unkeyed `Func<>` services are
ignored to avoid accidental collisions.
- The delegate returns the dialog result instead of calling
`args.SetResult(...)`. This matches the built-in async platform pattern
where MAUI completes the argument after awaiting the native dialog
result.
- Per-operation fall-through is intentional. A backend can override just
alerts and keep the platform action sheet/prompt, for example.
- `OnPageBusy` is excluded from the convention (obsolete in .NET 10,
removed in .NET 11) and always routes to the fallback.
- When .NET 11 ships a proper public
`IAlertManager`/`IAlertDialogProvider`, this delegate convention can
stay as a lightweight alias or be deprecated. Either way, .NET 10
consumers are unblocked now.

### Tests

Unit coverage in `AlertManagerTests.cs` includes:

- Alert, action sheet, and prompt delegate dispatch
- Returned delegate results completing the original `Display*Async`
caller
- Unregistered operation fall-through
- Unkeyed delegate services being ignored
- Explicit `IAlertManagerSubscription` precedence
- Synchronous and asynchronous delegate faults
- Delegate cancellation forwarding
- Null task contract violation

Focused `AlertManagerTests` pass locally: 21/21.

### Issues Fixed

Fixes #34104

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Alert/Dialog system (DisplayAlert, DisplayActionSheet, DisplayPromptAsync) needs a public extensibility point

4 participants