Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

Shell Releases

Fix
IL2077 trim/AOT warning from ShinyAppBuilder resolved — mapped pages and ViewModels were registered with AddTransient(Type) from types held in a tuple, which cannot carry [DynamicallyAccessedMembers], so the annotation on Add<TPage, TViewModel>() was lost by the time the registration happened. The registration is now captured while the types are still annotated generics, so their public constructors are preserved under trimming and Native AOT without a suppression. No API or behaviour change.
Feature
Navigation interceptors — INavigationInterceptor — a guard that runs before every navigation and can let it through, cancel it, or redirect it, with AddNavigationInterceptor<T>() registering as many as you like (they run in registration order, first to cancel or redirect wins). App links are what made this necessary: an inbound URL arrives from outside the app, at any route, at any moment, and “is this user allowed to see this page?” is not a question the caller can answer because there is no caller. Every navigation path funnels through one pipeline — NavigateTo by route and by ViewModel, the navigation builder, GoBack/PopToRoot, app links, app shortcuts, and Shell-driven navigation the user starts by tapping a tab or the back button — so a guard written once cannot be walked around. ShowDialog and SwitchShell are not navigation and are not intercepted.
Feature
The destination ViewModel is resolved before the interceptor runs — fully populated (a configure callback has run, an app link’s values are already bound) and handed over as the second argument, so a guard can decide on the destination’s own state rather than string-matching its URI. It is the same instance that gets bound to the page, so an interceptor can also fix a destination up instead of blocking it. It is null only where Shiny does not build the destination: an unmapped route, or a tab tap where Shell constructs the page itself.
Feature
NavigationInterceptorResult.Redirect<TViewModel>() — alongside Redirect(uri), a refactor-safe redirect that resolves the route from the ViewModel map, defaulting to a stack reset (which is what a guard sending the user to a login page wants) with relativeNavigation: true to push instead. A single leading / in a string redirect is promoted to //, because "/login" is what everybody writes and means nothing to Shell on its own. A redirect re-runs the entire chain against the new URI — the abandoned destination’s ViewModel is dropped rather than bound to anything, and the redirect target is guarded just as thoroughly, so one guard can hand off to another. Redirecting to the URI already being navigated to is ignored rather than looping, which is what makes an unconditional “go to login” guard safe on the login page itself; a genuine A → B → A loop throws after 10 hops instead of hanging.
Feature
INavigationContextAccessor — the rest of the navigation an interceptor’s two arguments cannot carry: FromUri, FromViewModel (the page being left, which is what an unsaved-changes guard needs), ToUri, NavigationType, Parameters and RedirectCount. Injected like IHttpContextAccessor rather than passed on the interface, so the interceptor contract stays two arguments wide and adding context later costs no one a recompile. Interceptors run on the main thread, so a confirmation dialog can be awaited directly.
BREAKINGEnhancement
Navigation methods return Task<bool>NavigateTo (route and ViewModel), GoBack, PopToRoot and INavigationBuilder.Navigate now report whether the navigation actually happened; false means an interceptor cancelled it. A redirect returns true, because the navigation did happen, elsewhere. Without this a guarded navigation completed silently and the caller had no way to know its await had done nothing. Generated NavigateTo{Route} extensions return Task<bool> too, and the AI tool’s NavigateToRoute now answers “blocked by the application” instead of reporting a success that did not occur. Callers that ignore the result are unaffected; implementers of INavigator need to update their signatures.
Feature
INavigationBuilder.BypassInterceptors() — the fluent form of Navigate(bypassInterceptors: true), because a builder chain should read as one. Callable anywhere in the chain (unlike PopBack, which has to come first), and either way of asking counts.
Feature
bypassInterceptors on every navigation method — the escape hatch for the navigation a guard itself performs, which must not be guarded again: NavigateTo<LoginViewModel>(bypassInterceptors: true), GoBack(1, bypassInterceptors: true), PopToRoot(bypassInterceptors: true), CreateBuilder()…Navigate(bypassInterceptors: true), and the generated NavigateTo{Route} methods. A RedirectUri deliberately does not need it — re-running the chain on a redirect is the point. It skips the interceptors only: INavigationConfirmation is a separate, ViewModel-level guard and is unaffected.
Feature
Interceptor ordering — INavigationInterceptor.Order — a default interface member (so nothing existing has to implement it) with lowest-first ordering and registration order for ties. Guards go below zero, observers above, and neither registration has to know about the other. The inline AddNavigationInterceptor(delegate) overload takes an order argument for the same reason.
Feature
Interceptors receive the navigation’s CancellationTokenInterceptNavigationAsync(uri, viewModel, cancellationToken), fed by a new cancellationToken argument on the navigation methods, for the network call an auth guard makes. It is checked between interceptors, so cancelling abandons the navigation with an OperationCanceledException rather than letting the remaining guards run. Distinct from NavigationInterceptorResult.Cancel(), which is the guard’s own decision.
Feature
NavigationDirectionForward (push), Back (GoBack, PopToRoot) or Root (an absolute route, a Shell swap), on NavigationContext, NavigationEventArgs and NavigatedEventArgs, with NavigationType.GetDirection() for anywhere else. NavigationType already said precisely what was happening; a guard that only wants “is the user going back?” should not have to enumerate three cases to find out.
BREAKINGEnhancement
IAppLinks.Handle returns AppLinkResultNavigated, Blocked (a guard cancelled it) or Unhandled (nothing matched) instead of a bool that could not tell “routed” from “turned away”. The platform hooks still report a blocked link as handled: telling iOS otherwise invites it to open the URL in a browser, which is the opposite of what a guard that just blocked it wants. Windows callers forwarding activation by hand should treat anything other than Unhandled as handled.
Chore
INavigationConfirmation is unchanged and still asked first — it stays scoped to user-driven Shell navigation (tab tap, flyout item, hardware back button), which is what it has always covered, and runs before the interceptor chain on that path. The scope is now stated in the docs rather than left to be discovered: programmatic navigation does not consult it, and an app-wide rule that must also cover INavigator calls, app links and shortcuts belongs in an interceptor.
Enhancement
One navigation path inside the navigator — every entry point now builds a NavigationRequest and goes through the same interception, pinning and rollback, with exactly one Shell.GoToAsync call site in the library (locked in by a test, because a second one would silently bypass every guard). Interceptor exceptions propagate to the caller and cancel the navigation — a guard that fails is never treated as a guard that passed — and on Shell-driven navigation, where there is no caller, they are logged and the navigation is cancelled. A navigation that fails now also clears the internal programmatic-navigation flag, which previously could wave the next user-driven navigation past INavigationConfirmation.
Fix
App links now also hook the UISceneDelegate variantsSceneOpenUrl and SceneContinueUserActivity alongside the AppDelegate pair. MauiUISceneDelegate raises only the Scene-prefixed lifecycle events and does not forward to the others, so an app declaring UIApplicationSceneManifest (multi-window iPad) previously had silently dead custom-scheme links. iOS calls one delegate or the other and never both, so hooking both cannot double-deliver.
Fix
Duplicate activations are suppressed — an identical link arriving within a second of the previous one is ignored. Android re-runs OnCreate with the original intent when the activity is recreated, which would otherwise push the destination page a second time; the same guard covers a hand-forwarded call overlapping a hooked one. The window is deliberately short so a user genuinely re-opening the same link is still honoured.
Feature
Localized shortcut titles via IAppShortcutText — the Shortcut and ShortcutSubtitle strings are attribute literals and cannot be translated on their own, so register a resolver with UseShortcutText<T>() and the declared string becomes a resource key with the literal as its own fallback. Resolution runs at install time, when CurrentUICulture is known, and applies to generated and hand-registered shortcuts alike. Because installed shortcuts keep their text until pushed again, IAppShortcuts.Refresh() re-resolves and re-pushes the set after a language change — without it, “localized” would only mean “localized as of last launch”. Apps that do not register a provider pay nothing: the default returns the declared strings, which are already installed.
Enhancement
AddGeneratedMaps() installs everything declared on [ShellMap] — routes, app links and app shortcuts — so declaring a template or a Shortcut title is the opt-in. UseAppLinks(...) survives as optional tuning of AppLinkOptions only, and UseAppShortcuts() is gone entirely (it had nothing to configure). This removes a whole class of silent misconfiguration: “I declared an app link and nothing happens because I forgot the second call” is no longer reachable, and the two startup warnings that existed to report it are deleted along with it. The platform hooks are still only installed when something is actually declared, so an app with no links or shortcuts hooks nothing.
Feature
App Shortcuts — home screen quick actions (iOS long-press menu, Android app shortcuts) declared with named properties on [ShellMap]: Shortcut, ShortcutSubtitle, ShortcutIcon, ShortcutOrder. Setting Shortcut is what declares one; the route becomes the shortcut’s id, so there is no magic string to keep in sync and no hand-written switch over activations — which is the entire boilerplate MAUI’s own AppActions leaves you with. Named properties rather than a separate [AppShortcut] attribute, because a shortcut without a route mapping is inert anyway and the constructor stays at four parameters. Platform delivery is MAUI’s AppActions, so there is no AppDelegate, MainActivity or manifest work, and push-vs-reset is inferred from registerRoute exactly as it is for app links.
Feature
AddAppShortcut<TViewModel>(...) — the public registration API the generated AddGeneratedMaps() emits calls to, so turning source generation off does not take the feature with it. It also handles what the attribute cannot: a configure lambda populates the ViewModel on activation, which is how a shortcut targets a route with required parameters. The lambda survives app restarts because only the id is persisted by iOS and Android — the registration is rebuilt every launch and resolved by id, so nothing needs serializing. Give an explicit id when two shortcuts target the same route with different values.
Enhancement
Three new diagnostics. SHINY010 (error) — Shortcut on a route with a required [ShellProperty]; an attribute cannot supply a runtime value, and the message names the offending property and points at AddAppShortcut(configure:). SHINY011 (warning) — more than four shortcuts, because iOS drops the excess silently and nothing anywhere tells you why; AddAppShortcut logs the equivalent at runtime for hand-registered sets. SHINY012 (error) — a Shortcut* property set without Shortcut, which is the one thing named properties give up versus a constructor parameter: ShortcutIcon = "search" alone would otherwise declare nothing, silently.
Enhancement
AppLinkRoutes.Build gained a primitive overload taking (route, registerRoute, coldStart, defaultRoot), with the app-link overload delegating to it after applying ResolveRoute. App shortcuts share the push-vs-reset rule rather than reimplementing it, so a quick action lands exactly where a link to the same route would.

Two signature changes, both compile errors rather than silent behaviour shifts:

Navigation methods return Task<bool>. Callers that await and ignore the result need no change:

await navigator.NavigateTo<DetailViewModel>(); // still fine
// and now you can ask
if (!await navigator.NavigateTo<DetailViewModel>())
logger.LogInformation("a guard turned that away");

Implementers of INavigator or INavigationBuilder (test doubles, mostly) update their return types to Task<bool> and pick up the new bypassInterceptors / cancellationToken parameters.

IAppLinks.Handle returns AppLinkResult. Only relevant if you forward activation by hand (Windows, or a platform hook MAUI does not surface):

// Before
var handled = await appLinks.Handle(uri);
// After
var handled = await appLinks.Handle(uri) != AppLinkResult.Unhandled;

INavigationConfirmation, INavigationAware, IPageLifecycleAware, [ShellMap], [ShellProperty] and the generated navigation methods are otherwise source-compatible — the generated NavigateTo{Route} extensions simply return Task<bool> and gained two optional parameters.

Feature
App Links — inbound deep linking declared where the route already is, through a new appLinks argument on [ShellMap]: [ShellMap<ProductPage>(appLinks: ["product/{id}", "p/{id}"])]. A {token} path segment binds to the [ShellProperty] of the same name case-insensitively, query string values bind by property name too, and a path token wins over a query value of the same name. Templates carry no scheme or host, so any configured scheme or domain serves any template and adding a domain later needs no attribute change. A separate [AppLink] attribute was considered and rejected — it only earns its keep if it carries per-template options, and inferring the navigation mode (below) removes the need for those, after which one attribute wins on every other axis, including making “an app link without a route” unrepresentable rather than a diagnostic.
Feature
Push or reset is inferred from registerRoute, never configuredregisterRoute: false already means “this page is a ShellContent in my AppShell XAML”, so an inbound link resets the stack with //route; a Routing.RegisterRoute’d detail page pushes. This is not a heuristic — a ShellContent cannot be pushed at all, so it is the only correct navigation in each case, and the answer was already in the declaration. On a warm start a pushed link lands on top of wherever the user was; on a cold start it lands on Shell’s first item, or on AppLinkOptions.DefaultRoot when set. ResolveRoute remains as the escape hatch for a Shell whose structure breaks the convention.
Feature
The platform delivery points are installed for you — iOS OpenUrl and ContinueUserActivity, Android OnCreate and OnNewIntent, all registered through MAUI’s lifecycle events from inside the library by AddGeneratedMaps(). Your AppDelegate, MainActivity and App classes stay untouched. The first design was a ShinyApplication : Application base class overriding OnAppLinkRequestReceived, mirroring ShinyShell : Shell; lifecycle events reach strictly more (MAUI does not forward OpenUrl to the app at all, so custom schemes were unreachable that way) while asking for strictly less. Windows has no automatic hook — forward protocol activation to the public IAppLinks.Handle(uri).
Feature
Source-generated value binding[ShellProperty] values previously only ever arrived as already-typed CLR values through a configure lambda; nothing converted a URL string onto a ViewModel property. Each template now emits a typed binder — no reflection, so it survives a trimmed PublishAot build. string, all integral types, float/double/decimal, bool, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Uri, enums (by name, case-insensitively) and nullable variants of each are supported; anything else is a SHINY006 build error rather than a runtime surprise. Everything parses with InvariantCulture, so a German device does not reject 1.5. A missing or unparseable required value makes that template fail to bind and the router falls through to the next candidate, then to OnUnhandled — a malformed inbound URL is a routing miss, never a crash.
Feature
Create{Route}AppLink(...) for outbound URLs — the templates are known at compile time, so the share-sheet direction comes nearly free: navigator.CreateProductAppLink(id: 42, tab: "reviews"). Only generated when exactly one scheme (or one domain, with no scheme) is configured, because with several there is no single correct base to build against.
Feature
Manifest validation with pasteable markupShinyAppLinkSchemes and ShinyAppLinkDomains drive build warnings SHINY101SHINY105 naming the file and carrying the exact [IntentFilter] attribute or plist XML to add. The build deliberately does not edit your manifests: Android’s merged manifest names the launcher activity with a CRC64 hash of its namespace that MSBuild cannot compute, so a generated overlay would add a second activity rather than amend the real one; and Apple universal links need an apple-app-site-association file on the domain plus the Associated Domains capability on the App ID regardless. Android validates the merged manifest, so [IntentFilter] attributes are seen correctly. Set ShinyAppLinkValidation=false to silence. Note that creating Platforms/iOS/Entitlements.plist is sufficient on its own — the SDK picks it up with no CodesignEntitlements property needed.
Enhancement
Matching is ordered by specificity — literal segments beat tokens, so product/featured is tried before product/{id} regardless of declaration order, and if the more specific template fails to bind its values the next candidate still gets a turn. Two templates of the same shape are a SHINY007 build error, so the runtime never has to guess. App link navigation reuses the same pinned-ViewModel path as NavigateTo<TViewModel> rather than opening a second one that would have to rediscover its Android timing behaviour.
Enhancement
New diagnosticsSHINY005 (template token with no matching [ShellProperty]), SHINY006 (unconvertible property type), SHINY007 (ambiguous templates), SHINY008 (templates declared with no scheme or domain configured), SHINY009 (a required property that is not a token in the template, so inbound links must supply it as a query value).
Chore
The source generator now reads attributes semanticallyAttributeData/TypedConstant instead of hand-walking AttributeSyntax positional arguments, deleting roughly 120 lines of special-case parsing (including “registerRoute is the first argument when route is omitted”). Array arguments read identically whether written as new[] { ... } or a collection expression, and [ShellProperty] properties are now collected from the ViewModel symbol, so partial classes contribute all of theirs.
Feature
ViewModel dialogs — INavigator.ShowDialog<TViewModel, T> — present one of your own Page/ViewModel pairs as a dialog and await a strongly typed result from it. The ViewModel implements the new IDialogAware<T> (two events: Completed carrying the value, and Cancelled) and must be mapped to a page like any other navigable ViewModel. IDialogs still covers alert/confirm/prompt/action sheet; this is for the cases that need real UI to collect a result — a colour picker, a filter sheet, a signature pad. IPageLifecycleAware and IDisposable on a dialog ViewModel behave exactly as they do for a navigated page, and the page does not need Shell.PresentationMode="Modal" — it is pushed modally for you.
Feature
Source-generated Show{Route}Dialog extensions — a new DialogExtensions.g.cs emits a fully inferred wrapper for every [ShellMap] ViewModel that also implements IDialogAware<T>, so the call site needs no type arguments at all and [ShellProperty] values become method parameters: await navigator.ShowPickColorDialog(preset: "Violet"). This exists because C# cannot infer a type argument from a constraint — calling ShowDialog directly always requires both type arguments spelled out. The file is not generated when no dialog-aware ViewModels exist, is gated by the existing ShinyMauiShell_GenerateNavExtensions property alongside the other nav extensions, and is deliberately excluded from the AI tool surface — an AI agent should be driving navigation, not blocking on a modal awaiting human input.
Feature
DialogResult<T> — the return type of ShowDialog, with IsCancelled, Value, TryGetValue(out T) and ValueOr(fallback). A distinct result type rather than Task<T> because default(T) cannot express cancellation for value types — a bool dialog could not otherwise distinguish “the user chose No” from “the user dismissed the dialog”. Every dismissal path completes the awaiting task: a ViewModel raising Cancelled, and the user dismissing the dialog without either event being raised (hardware back, an iOS modal swipe-down), both produce a cancelled DialogResult<T> instead of hanging. A CancellationToken passed by the caller is kept distinct and still surfaces as an OperationCanceledException.
Feature
IDialogPresenter + UseDialogPresenter<TPresenter>() — how a dialog appears is swappable. The default ShellModalDialogPresenter pushes the page onto the Window’s modal stack (rather than navigating to a route with Shell.PresentationMode="Modal" declared in XAML), which presents modally regardless of the page’s XAML and avoids the ShellNavigationConfigurator pinning race by handing the navigator the exact page instance. It deliberately uses Window.Navigation rather than Shell.Navigation — the latter is a NavigationProxy whose OnPopModal becomes Shell.GoToAsync("..") outside an active Shell navigation, which would run the INavigationConfirmation guard and pop whatever Shell believes is current instead of the dialog — and detects dismissal via Element.ParentChanged rather than Window.ModalPopped, because ModalNavigationManager.SyncPlatformModalStackAsync (which reconciles a platform-initiated dismissal) detaches the page without raising ModalPopped. A presenter implements a single method — show the page, complete the task once it’s gone — while resolving, configuring, awaiting and tearing down all stay in the navigator.
Enhancement
Full ViewModel lifecycle on dialogsIPageLifecycleAware.OnAppearing/OnDisappearing and IDisposable.Dispose all fire on a dialog ViewModel, and the page underneath receives OnDisappearing when the dialog opens and OnAppearing when it closes. INavigationAware, INavigationConfirmation and the Navigating/Navigated events are deliberately not involved — showing a dialog is not a navigation stack mutation, and an “are you sure you want to leave?” guard firing because a dialog opened would be wrong. Note that the dialog ViewModel is disposed as its page detaches, marginally before ShowDialog returns; the returned DialogResult<T> is captured when the ViewModel raises its event and is unaffected, but the ViewModel instance should not be used after the await.
Feature
Dialog presenters for Shiny Controls and UXDivers Popups — both dialog packages now ship an IDialogPresenter, so a ViewModel dialog shown with ShowDialog renders as a card over a dimmed backdrop instead of a Shell modal page. Shiny.Maui.Shell.ShinyDialogs adds UseShinyDialogPresenter(), which floats the dialog page’s content in a themed card using the active Shiny theme’s Surface and Scrim colours — on a ShinyContentPage it goes into that page’s own OverlayHost, and on a plain ContentPage the content is wrapped once in a Grid and the overlay layered on top. Shiny.Maui.Shell.UxDiversDialogs adds UseUxDiversDialogPresenter(), which hosts the content in a UXDivers PopupPage built the way their own custom popups are, so a ViewModel dialog matches the alert/confirm/prompt popups beside it. Neither changes the ViewModel, the IDialogAware<T> contract, or the call site. Both take options for BackdropOpacity, BackdropColor, DismissOnBackdropTap, CornerRadius, CardBackgroundColor, MaxWidth, Margin and AnimationDuration, plus an escape hatch (ConfigureCard / ConfigurePopup) for anything else.
Feature
ViewDialogPresenter — the base class both new presenters are built on, for presenting a dialog into a host that takes a View rather than a Page (a popup, a bottom sheet, an overlay). The navigator resolves a Page because that is what the route map holds, so this unwraps it and hands the subclass just the content — then restores the three things a page would otherwise have given for free: the binding context (which no longer arrives by inheritance once the content is re-parented), IPageLifecycleAware (driven by Application.PageAppearing, which only fires for a real page), and disposal of an IDisposable ViewModel (the navigator disposes on DescendantRemoved, and a page that never entered the tree is never removed). The content is handed back to its page afterwards.
Enhancement
The page underneath a dialog stays on screen with the overlay presenters — and therefore receives neither OnDisappearing when the dialog opens nor OnAppearing when it closes, unlike the modal default. The dialog ViewModel’s own lifecycle is identical under every presenter. ShinyOverlayDialogPresenter additionally treats the host page disappearing as a dismissal: an overlay lives inside a page, so a navigation away (an Android back press, a tab switch, a programmatic GoBack) takes the dialog with it, and the awaiting caller is released with a cancelled DialogResult<T> rather than left hanging.
Enhancement
UXDivers popup infrastructure is initialized onceUseUxDiversDialogs() and UseUxDiversDialogPresenter() each call UseUXDiversPopups() for you, and calling both no longer installs the platform handlers (the Android back button among them) twice. Neither extension needs a builder.UseUXDiversPopups() call of your own.
Enhancement
ShinyAppBuilder.GetPageTypeForViewModel(Type) — the counterpart to the existing GetRouteForViewModel / GetPageTypeForRoute, used by ShowDialog to resolve a dialog ViewModel’s page directly rather than going through a route.
Fix
Source generator no longer emits the spurious SHINY002 warning when AI extensions are disabled — the else branch that raises SHINY002 (NavExtensionsDisabledWithMaps — “AddGeneratedMaps skipped because nav extensions are disabled”) was mistakenly attached to the AI-extensions if rather than the nav-extensions if. Because AI extension generation is off by default, the diagnostic fired on virtually every build even though nav extensions were enabled and AddGeneratedMaps was generated correctly. The else is now bound to the nav-extensions check, so SHINY002 only reports when nav extensions are genuinely disabled, and AI extension generation runs on its own independent condition.
Fix
UxDiversDialogs no longer resolves with the wrong result when an action button is tapped — the v6.3.0 outside-dismiss fix subscribed every popup to PopupClosed to complete the awaiting TaskCompletionSource on tap-outside. But PopupServiceCore raises PopupClosed inside PopAsync (before its task completes), so tapping an action button fired the OnPopupClosed handler too — racing the button command’s own TrySetResult and frequently completing the TaskCompletionSource with the cancel/default value first. The result: Confirm could return false after the user tapped Accept, Prompt could return null after the user tapped OK, and ActionSheet could return the cancel value after a real selection. Each action command (Alert/Confirm buttons, Prompt accept/cancel, every ActionSheet item plus its close button) now unsubscribes OnPopupClosed before calling PopAsync and resolves the result itself; OnPopupClosed only handles genuine dismissal without a selection (tap-outside / back button), where it still falls back to the cancel/default value.
Feature
Shiny.Maui.Shell.ShinyDialogs package — a new IDialogs provider backed by the owned, animated, themeable dialog service (IDialogService) from Shiny.Maui.Controls. Unlike the default ShellDialogs (which uses the native platform alert/prompt), these dialogs are always rendered by the library, so they look identical across platforms and follow your theme tokens. Register with UseShinyControls() (which registers the underlying IDialogService) plus UseShinyShell(x => x.UseShinyDialogs()) — ViewModels keep injecting IDialogs unchanged. Alert, Confirm, Prompt (including initialValue, maxLength, and keyboard), and ActionSheet all map onto the Controls dialog service. Requires Shiny.Maui.Controls 1.0.1-beta-0127 or later.
Fix
UxDiversDialogs no longer hangs when a popup is dismissed by tapping outsideAlert, Confirm, Prompt, and ActionSheet awaited a TaskCompletionSource that was only completed by a button tap. When the user dismissed the popup by tapping outside it, the popup closed but the TaskCompletionSource was never completed, so the awaiting await dialogs.Alert(...) (and the others) hung indefinitely. Each method now subscribes to PopupClosed and returns a sensible default on outside-dismiss — Alert completes normally, Confirm returns false, Prompt returns null, and ActionSheet returns cancel ?? string.Empty — and unregisters the PopupClosed handler so it doesn’t leak onto subsequent calls.
Fix
NavigateTo<TViewModel>(relativeNavigation: false) no longer races against Shell.OnNavigated / Application.PageAppearing — on Android, absolute navigation between <ShellContent>-declared routes (e.g. StartupViewLoginView triggered from inside IPageLifecycleAware.OnAppearing) could complete the Shell.GoToAsync awaiter before Shell’s CurrentItem chain reflected the new section, so Shell.Current.CurrentPage returned null (or the stale previous page) and before any of ShinyShell.OnNavigated, Application.PageAppearing, or ShinyRouteFactory.GetOrCreate had run. The navigator now resolves and configures the viewmodel synchronously up front, pins the instance on the configurator, fires GoToAsync, and returns. It no longer probes CurrentPage after the await — the pinned viewmodel + apply-site model handles the timing on whatever schedule Shell decides to raise its events.
BREAKINGEnhancement
ShellNavigationConfigurator API changed — the queue now holds pre-resolved viewmodel instances instead of Action<T> callbacks. Enqueue<T>(Action<T>) and TryApply(object) are replaced with EnqueueResolved<T>(T instance, Action<T>? configure = null), the non-generic EnqueueResolved(Type, object) (for INavigationBuilder), and TryConsume(Type). Configure callbacks now run synchronously inside EnqueueResolved before enqueue, so every downstream hook (OnAppearing, INotifyPropertyChanged subscribers) observes a fully initialised viewmodel. The configurator lives in Shiny.Infrastructure and was always documented as internal-use, so most consumers will not be affected.
Enhancement
Apply sites are now consume-then-fallbackShinyRouteFactory.GetOrCreate, ShinyShell.OnNavigated, and ShinyShellNavigator.AppOnPageAppearing first call ShellNavigationConfigurator.TryConsume(viewModelType) and only fall back to IServiceProvider.GetService when no pinned instance exists (the initial-page case). Whichever site fires first wins; the others see BindingContext is TViewModel already true and skip re-assignment. No duplicate transient viewmodel instances are created across the apply sites.
Enhancement
INavigationBuilder.Navigate pre-resolves every typed segment — each Add<TViewModel>(configure) segment is now resolved, configured, and pinned before Shell.GoToAsync runs. The previous NavigationStack walk after the await — which raced against PageAppearing on Android the same way NavigateTo<TVm> did — has been removed; the apply sites consume the pinned instances in FIFO + type order matching the order Shell realises each segment’s page.
Enhancement
Pinned subscriptions roll back only on navigation failure — both NavigateTo<TViewModel> and INavigationBuilder.Navigate now dispose pinned configurator entries only when Shell.GoToAsync throws (e.g., unknown route). On success the entries remain pinned until consumed by an apply site, because the apply sites typically run on the next dispatcher tick on Android — releasing the entries immediately after the await would force a fallback DI resolve and discard the configured instance.

If you don’t reference Shiny.Infrastructure.ShellNavigationConfigurator directly, no code changes are required — the fix is transparent. Existing NavigateTo<TViewModel>(configure) and INavigationBuilder.Add<T>(configure) calls continue to compile and behave the same, with the only observable change being that the configure callback now runs before any IPageLifecycleAware.OnAppearing hook instead of racing against it on certain platforms.

If you do call the configurator directly (rare — it was always intended as a navigator-private helper), the migration is mechanical:

// Before (v6.1.x) — queue a configure callback, applied later by an apply site
using var sub = configurator.Enqueue<DetailViewModel>(vm => vm.ItemId = 42);
// (something else resolves the VM from DI and the configurator applies it)
configurator.TryApply(resolvedVm);
// After (v6.2) — pre-resolve and configure, pin the instance
var vm = services.GetRequiredService<DetailViewModel>();
using var sub = configurator.EnqueueResolved(vm, x => x.ItemId = 42);
// apply sites call configurator.TryConsume(typeof(DetailViewModel)) and get `vm`

Why this fix is structural rather than a guarded retry

Section titled “Why this fix is structural rather than a guarded retry”

v6.1 closed the original “navigator hangs waiting for ShinyRouteFactory.PageResolved” race by reading Shell.Current.CurrentPage.BindingContext directly after GoToAsync returns. That worked for registered routes (where ShinyRouteFactory.GetOrCreate sets BindingContext synchronously inside page construction) and usually worked for ShellContent-declared routes (where ShinyShell.OnNavigated sets it during the navigation). The remaining failure mode was a timing window on Android cross-Section absolute navigation, where Shell’s awaiter resolved before any Navigated event or PageAppearing event fired, leaving the new page with a null BindingContext at the exact moment the navigator looked.

A pure defensive fallback (resolve from DI and bind if null after the await) would close that specific symptom but would leave three independent resolution paths competing under variable platform timing — a family of bugs waiting for the next MAUI Shell lifecycle adjustment to surface a new variant, and a guarantee of duplicate viewmodel instances any time two paths raced in opposite orders.

v6.2 collapses the three paths into one. The viewmodel is resolved exactly once, in the navigator, before navigation starts. It’s pinned on the configurator so whichever apply site fires first consumes it. The navigator then fires GoToAsync and returns — it deliberately does not probe Shell.Current.CurrentPage afterwards, because Shell’s eventing and CurrentItem chain are platform-scheduled and racing them in the navigator was the original bug. The apply sites bind the pinned instance whenever Shell raises their event, on whatever dispatcher tick that lands on. One instance, one assignment, regardless of platform timing.

Fix
UseDialogs<TDialog> is now NativeAOT/trim-safe — the generic type parameter on ShinyAppBuilder.UseDialogs<TDialog>() was missing [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)], so iOS NativeAOT release builds (<PublishAot>true</PublishAot>) trimmed the dialog implementation’s constructor. At startup, DI would throw InvalidOperationException: A suitable constructor for type '<YourDialogs>' could not be located. This affected any custom IDialogs implementation registered via UseDialogs<T>(), most visibly Shiny.Maui.Shell.UxDiversDialogs since it ships a non-default constructor (IMainThread). The annotation now matches the one already present on Add<TPage, TViewModel>, so the trimmer preserves all public constructors of TDialog
Fix
NavigateTo<TViewModel>(configure) now applies configure before OnAppearing firesconfigure callbacks were running after Shell.GoToAsync returned, which meant IPageLifecycleAware.OnAppearing saw a default-state ViewModel. A ViewModel that read a configure-set property in OnAppearing (e.g. to fetch a record by id and bounce back via Navigator.GoBack() if not found) would observe null/default, navigate away, and trigger an InvalidOperationException("Page BindingContext is not of type ...") against the stale page when the post-nav callback tried to apply. This also affected the source-generated NavigateTo{Name} methods since they delegate to NavigateTo<TViewModel>(vm => ...) internally. Callbacks now run before the page’s BindingContext is set
Enhancement
ShellNavigationConfigurator service — new internal-but-public service in Shiny.Infrastructure that holds queued configure callbacks. Enqueue<T>(Action<T>) returns an IDisposable so the navigator can roll back unconsumed entries on navigation failure; TryApply(object) pops the first matching-type entry. Applied at three sites — ShinyRouteFactory.GetOrCreate (routed pages), ShinyShell.OnNavigated (ShellContent-declared pages), and ShellNavigator.AppOnPageAppearing (fallback) — whichever fires first wins. FIFO + type-keyed lookup means interleaved navigations to different ViewModel types don’t collide

No code changes required. The fix is transparent to consumers — configure callbacks that previously appeared to work because the VM’s property setters fired INotifyPropertyChanged after OnAppearing will continue to work; the only observable difference is that the same callbacks now also reach OnAppearing synchronously, which closes a class of latent races.

Fix
NavigateTo<TViewModel>(relativeNavigation: false) no longer hangs / crashes for ShellContent-declared routes — pages declared in AppShell.xaml as <ShellContent ContentTemplate="{DataTemplate ...}"> (with [ShellMap<TPage>(registerRoute: false)]) are constructed by MAUI via the DataTemplate and never go through ShinyRouteFactory.GetOrCreate. The previous implementation awaited the static ShinyRouteFactory.PageResolved event for completion — that event never fired for these pages, so the awaiter hung and the subscribed handler leaked. A subsequent unrelated navigation through a registered route would wake the leaked handler with the wrong page and throw InvalidOperationException("Page BindingContext is not of type '<original target VM>'") — on iOS surfaced asynchronously through NSAsyncSynchronizationContextDispatcher against a stale async void call site, which made the crash look like it originated from a much later, unrelated navigation. NavigateTo<TViewModel> now reads Shell.Current.CurrentPage.BindingContext directly after GoToAsync returns, which works uniformly for registered routes and ShellContent-declared routes
Fix
NavigationBuilder.Navigate no longer suffers cross-navigation handler crosstalk — the builder previously subscribed to the same static PageResolved event, so two concurrent builders (or a builder running alongside a NavigateTo<TVM>) could fire each other’s configure callbacks against the wrong page. Navigate now walks Shell.Current.Navigation.NavigationStack after GoToAsync returns and applies each segment’s configure callback to the page at the corresponding stack index, with a warning log when the resolved page’s BindingContext doesn’t match the segment’s expected ViewModel type
BREAKINGEnhancement
ShinyRouteFactory.PageResolved static event removed — the event lived in the Shiny.Infrastructure namespace and was only consumed internally by NavigateTo<TViewModel> and NavigationBuilder.Navigate. Both consumers have been rewritten and no longer need it. ShinyRouteFactory.GetOrCreate still resolves the page from DI and assigns BindingContext for registered routes — it just no longer broadcasts. If any external code was subscribing to this event for analytics or diagnostics, move that logic to INavigator.Navigated instead
BREAKINGEnhancement
configure callbacks now run after navigation completesNavigateTo<TViewModel>(configure: vm => ...) and NavigationBuilder.Add<TViewModel>(vm => ...) previously invoked configure synchronously inside ShinyRouteFactory.GetOrCreate, before the page was rendered. They now run after Shell.GoToAsync returns, so values set via configure propagate to the UI through INotifyPropertyChanged rather than being pre-set on a brand-new VM. For most ViewModels this is invisible; if a value must be present before the first render, migrate to [ShellProperty] parameters — the source-generated NavigateTo{Name} methods set [ShellProperty] values before page realization

No code changes are required for the common cases — NavigateTo<TViewModel>(relativeNavigation: false) against a ShellContent-declared route now works rather than hanging or eventually crashing, and existing configure callbacks continue to compile and run. Two corner cases worth checking:

  1. External subscribers to ShinyRouteFactory.PageResolved — the event was always part of the Shiny.Infrastructure namespace, but if you wired anything to it (logging, analytics, dev tooling), move that logic to INavigator.Navigated:

    // Before (v6.0.x)
    ShinyRouteFactory.PageResolved += (_, page) =>
    logger.LogDebug("Page resolved: {Type}", page.GetType());
    // After (v6.1)
    navigator.Navigated += (_, args) =>
    logger.LogDebug("Navigated to: {Uri}", args.ToUri);
  2. configure callbacks that depend on pre-render timing — if your callback sets values that the first render must read synchronously (e.g., a CollectionView.ItemsSource that can’t tolerate a re-bind), migrate those values to [ShellProperty] parameters and use the generated typed navigation method. [ShellProperty] values are applied inside the page-creation flow, before the UI renders:

    // Before — pre-render timing relied on configure
    await navigator.NavigateTo<DetailViewModel>(vm => vm.Items = preloadedItems);
    // After — declare as ShellProperty so the source-generated NavigateToDetail wires it pre-render
    [ShellProperty]
    public IReadOnlyList<Item> Items { get; set; }
    await navigator.NavigateToDetail(items: preloadedItems);
Fix
IApplication resolved at initialization instead of constructor injectionShinyShellNavigator now resolves IApplication from the service provider during Initialize instead of requiring it via constructor injection. This fixes DI resolution failures when IApplication is not yet available at service registration time
Fix
AI extensions generation decoupled from nav extensionsAiExtensions.g.cs is now generated independently when ShinyMauiShell_GenerateAiExtensions is true and the Microsoft.Extensions.AI package is present, regardless of the ShinyMauiShell_GenerateNavExtensions setting
Fix
Routes without properties now generate valid NavigateToRoute code — AI navigation for routes with no [ShellProperty] attributes now correctly calls NavigateTo<TViewModel>() without an empty lambda, fixing a compile error in the generated code
Fix
MSBuild targets packaged under correct filenamePackage.targets is now packed as Shiny.Maui.Shell.targets instead of Shiny.Maui.Shell.SourceGenerators.targets, ensuring MSBuild properties like ShinyMauiShell_GenerateAiExtensions are correctly imported by consuming projects
Fix
Source generator analyzer DLL uses $(Configuration) — the packed analyzer DLL path now respects the active build configuration instead of being hardcoded to Release, fixing development-time source generation when building in Debug
Feature
SHINY004 warning — new diagnostic warns when [ShellProperty] attributes have descriptions but the parent [ShellMap] has no description. AI tools cannot determine when to navigate to a route without a route-level description
Feature
ShinyMauiShell_AiToolsClassName MSBuild property — customize the generated AiMauiShellTools class name via this new build property (default: AiMauiShellTools)
Fix
ShinyMauiShell_GenerateAiExtensions now defaults to disabled (opt-in) — previously defaulted to enabled, causing compile error SHINY003 when Microsoft.Extensions.AI was not referenced. Projects must now explicitly set ShinyMauiShell_GenerateAiExtensions to true to enable AI extensions
Feature
AiMauiShellTools class — AI tools and prompt are now encapsulated in a generated AiMauiShellTools class that takes INavigator via constructor injection. Provides Prompt (pre-formatted route descriptions) and Tools (AITool[]) properties, plus GetAiToolApplicableGeneratedRoutes() and NavigateToRoute() instance methods. Designed for DI registration as a singleton
Feature
AddAiTools() extension — new generated extension method on ShinyAppBuilder that registers AiMauiShellTools as a singleton: builder.UseShinyShell(x => x.AddGeneratedMaps().AddAiTools())
Feature
ShinyMauiShell_AiToolsClassName MSBuild property — customize the generated AI tools class name (default: AiMauiShellTools)
Enhancement
AI extensions supportShinyMauiShell_GenerateAiExtensions enables AI tool generation when set to true. Requires Microsoft.Extensions.AI package (SHINY003 error if missing)
BREAKINGEnhancement
AI tools moved from extension methods to AiMauiShellTools classGetAiToolApplicableGeneratedRoutes(), NavigateToRoute(), AiRoutePrompt(), and GetAiTools() are no longer extension methods on INavigator. Instead, inject AiMauiShellTools and use its Prompt, Tools, and instance methods. GetGeneratedRouteInfo() remains as a static extension on INavigator

AI tools have moved from INavigator extension methods to the injectable AiMauiShellTools class:

// Before (v6.0) — extension methods on INavigator
var tools = navigator.GetAiTools();
var prompt = navigator.AiRoutePrompt();
var routes = navigator.GetAiToolApplicableGeneratedRoutes();
// After (v6.0.1) — inject AiMauiShellTools
// MauiProgram.cs
builder.UseShinyShell(x => x
.AddGeneratedMaps()
.AddAiTools() // registers AiMauiShellTools as singleton
);
// ViewModel
public class ChatViewModel(AiMauiShellTools aiTools)
{
var tools = aiTools.Tools;
var prompt = aiTools.Prompt;
var routes = aiTools.GetAiToolApplicableGeneratedRoutes();
}

AI extensions are now enabled by default. If you don’t use AI features and don’t have Microsoft.Extensions.AI installed, either install the package or explicitly disable:

<PropertyGroup>
<ShinyMauiShell_GenerateAiExtensions>false</ShinyMauiShell_GenerateAiExtensions>
</PropertyGroup>
Feature
AI-compatible source generation[ShellMap] now accepts a description parameter and [ShellProperty] now accepts description as the first parameter. When provided, the source generator emits XML doc comments, [System.ComponentModel.Description] attributes on methods and parameters, enabling AI tooling and IDE discoverability
Feature
GetGeneratedRouteInfo() extension method — new generated AiExtensions.g.cs produces a GetGeneratedRouteInfo() method on INavigator that returns an array of GeneratedRouteInfo records containing route names, descriptions, and parameter metadata. Designed for AI agents and tooling to discover available navigation routes at runtime
Feature
DisableShellFlyoutSwipeHandler — new opt-in custom handler that disables the Shell flyout swipe gesture while keeping the hamburger button functional. Call DisableShellFlyoutSwipeHandler.Register() in MauiProgram.cs to enable. Supported on Android (locks DrawerLayout), iOS/Mac Catalyst (disables UIPanGestureRecognizer), and no-op on Windows
BREAKINGEnhancement
ShellPropertyAttribute parameter order changeddescription is now the first parameter (string? description = null, bool required = true), replacing the previous (bool required = true) signature. Positional usage like [ShellProperty(true)] must change to [ShellProperty(required: true)]
Enhancement
GeneratedRouteInfo record — new Shiny.Infrastructure.GeneratedRouteInfo and GeneratedRouteParameter records provide structured route metadata for runtime inspection
Enhancement
IQueryAttributable no longer required for [ShellProperty] — the source-generated navigation methods (NavigateTo{Name}() and AI NavigateToRoute()) now set [ShellProperty] properties directly on the ViewModel instance. IQueryAttributable is only needed if you use string-based NavigateTo(route, args) with tuple parameters

The ShellPropertyAttribute constructor parameter order changed. Update any positional bool arguments to use named syntax:

// Before (v5.x)
[ShellProperty(true)]
public string Name { get; set; }
[ShellProperty(false)]
public string OptionalNote { get; set; }
// After (v6.0)
[ShellProperty(required: true)]
public string Name { get; set; }
[ShellProperty(required: false)]
public string OptionalNote { get; set; }
// New: add descriptions for AI tooling
[ShellProperty("The user's display name")]
public string Name { get; set; }
[ShellProperty("Optional note text", required: false)]
public string OptionalNote { get; set; }

Update [ShellMap] to include descriptions:

// Before (v5.x)
[ShellMap<DetailPage>("Detail")]
// After (v6.0) — description is optional
[ShellMap<DetailPage>("Detail", description: "Navigate to the detail page")]
using Shiny.Handlers;
// In MauiProgram.cs, before builder.Build()
DisableShellFlyoutSwipeHandler.Register();
Feature
Tab badgesINavigator now supports SetTabBadge(string route, int value), SetTabBadge&lt;TViewModel&gt;(int value), ClearTabBadge(string route), and ClearTabBadge&lt;TViewModel&gt;(), enabling numeric badges on Shell tabs by route or ViewModel mapping
Feature
XAML navigation — new Navigate attached properties enable route-based navigation directly from XAML on Button, MenuItem, and ToolbarItem, including support for a single parameter pair or a NavigationParameters collection
Enhancement
Native badge support — tab badges are implemented natively for Android, iOS, Mac Catalyst, and Windows. Unsupported platforms such as Linux and macOS AppKit now throw PlatformNotSupportedException instead of silently doing nothing
Feature
UxDivers Dialogs — new Shiny.Maui.Shell.UxDiversDialogs package provides an alternative IDialogs implementation powered by UXDivers Popups. Drop-in replacement — no ViewModel changes needed, only the visual presentation changes
Feature
UseUxDiversDialogs() extension methods — two extension methods for setup: MauiAppBuilder.UseUxDiversDialogs() initializes the UxDivers popup infrastructure, and ShinyAppBuilder.UseUxDiversDialogs() registers the IDialogs implementation

Install the package and the UxDivers dependency:

Terminal window
dotnet add package UXDivers.Popups.Maui

Add theme dictionaries to App.xaml:

<uxd:DarkTheme xmlns:uxd="clr-namespace:UXDivers.Popups.Maui.Controls;assembly=UXDivers.Popups.Maui" />
<uxd:PopupStyles xmlns:uxd="clr-namespace:UXDivers.Popups.Maui.Controls;assembly=UXDivers.Popups.Maui" />

Configure in MauiProgram.cs:

builder
.UseMauiApp<App>()
.UseUxDiversDialogs() // Initialize UxDivers popup infrastructure
.UseShinyShell(x => x
.UseUxDiversDialogs() // Register as IDialogs provider
.AddGeneratedMaps()
)
IDialogs Method UxDivers Popup Used
Alert SimpleActionPopup (single button)
Confirm SimpleActionPopup (two buttons)
Prompt FormPopup with single FormField
ActionSheet OptionSheetPopup with OptionSheetItem per button
BREAKINGEnhancement
AddGeneratedMaps() is no longer generated when nav extensions are disabled — setting ShinyMauiShell_GenerateNavExtensions to false now also prevents generation of NavigationBuilderExtensions.g.cs (AddGeneratedMaps). A SHINY002 warning is emitted when [ShellMap] attributes are detected but nav extensions are disabled
Enhancement
AddGeneratedMaps() is no longer generated when no maps existNavigationBuilderExtensions.g.cs is now only produced when at least one [ShellMap] attribute is present, removing the empty stub that was previously always emitted
  • If you relied on AddGeneratedMaps() being available before adding any [ShellMap] attributes, add at least one [ShellMap] to generate the method
  • If you set ShinyMauiShell_GenerateNavExtensions to false but still used AddGeneratedMaps(), either remove the property or replace AddGeneratedMaps() with manual .Add<TPage, TViewModel>() calls
Feature
INavigationBuilder — fluent builder for multi-segment Shell navigation URIs. Chain Add<TViewModel>(), Add(string), and PopBack() calls, then execute with a single Navigate(). Supports relative, root (fromRoot: true), and mixed pop/push patterns like ../../Page1/Page2
Feature
INavigator.CreateBuilder(bool fromRoot) — factory method to create an INavigationBuilder instance. Pass fromRoot: true to build absolute URIs prefixed with //
Feature
Generated INavigationBuilder extensions — source generator now produces NavigationBuilderNavExtensions.g.cs with typed Add{Name}() methods for each [ShellMap] ViewModel, enabling fluent chains like .AddDetail(id: 42).AddModal().Navigate()
BREAKINGEnhancement
relativeNavigation parameterNavigateTo(string) and NavigateTo<TViewModel>() now accept bool relativeNavigation = true. When false, the URI is prefixed with // for root navigation. Replaces the previous SetRoot<TViewModel>() method
Enhancement
Generated NavigationExtensions — all generated NavigateTo{Name}() methods now include a bool relativeNavigation = true parameter
  • SetRoot<TViewModel>() has been removed — use NavigateTo<TViewModel>(relativeNavigation: false) instead
  • Calls to NavigateTo that pass tuple args as positional parameters may need the args: named parameter to disambiguate from the new bool relativeNavigation parameter
// Before (v3.x)
await navigator.SetRoot<HomeViewModel>(vm => vm.Message = "Hello");
await navigator.NavigateTo("details", ("Id", 42));
// After (v4.0)
await navigator.NavigateTo<HomeViewModel>(vm => vm.Message = "Hello", relativeNavigation: false);
await navigator.NavigateTo("details", args: [("Id", 42)]);
// New: Navigation Builder
await navigator
.CreateBuilder()
.AddDetail(id: 42)
.AddModal()
.Navigate();
// New: Pop back and push
await navigator
.CreateBuilder()
.PopBack(2)
.AddHome()
.Navigate();
Fix
MainThread dispatch on macOS & LinuxMauiMainThread now bypasses MainThread.InvokeOnMainThreadAsync on macOS and Linux, where MAUI’s implementation fails or deadlocks. INavigator and IDialogs dispatch calls now work reliably on both platforms
Feature
IMainThread interface — thread-marshalling abstraction used internally by ShellNavigator and ShellDialogs, now registered as a singleton so you can inject it directly instead of using Microsoft.Maui.ApplicationModel.MainThread
Feature
ShellServices record — convenience aggregate of INavigator, IDialogs, and IMainThread — inject a single parameter when a ViewModel needs most of them
Feature
UseDialogs<TDialog>() — plug in a custom IDialogs implementation via UseShinyShell(x => x.UseDialogs<MyDialogs>()). The default ShellDialogs registration now uses TryAddSingleton so user overrides always win
Feature
ShinyShell base class — new ShinyShell class that overrides OnNavigated to deterministically set the initial page’s BindingContext via Shell’s own lifecycle, eliminating a race condition where the Application.PageAppearing event could fire before the handler was registered
Fix
BindingContext inheritance fix — BindingContext checks now correctly detect inherited values (e.g., the Shell instance propagated down the visual tree) instead of only checking for null, ensuring ViewModels are always assigned to their mapped pages

Your AppShell (and any other Shell subclass) must now inherit from ShinyShell instead of Shell:

AppShell.xaml — change root element:

<shiny:ShinyShell
x:Class="MyApp.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:shiny="clr-namespace:Shiny;assembly=Shiny.Maui.Shell"
xmlns:local="clr-namespace:MyApp"
Title="MyApp">
<!-- ... -->
</shiny:ShinyShell>

AppShell.xaml.cs — change base class:

using Shiny;
public partial class AppShell : ShinyShell
{
public AppShell()
{
InitializeComponent();
}
}
Fix
INavigationConfirmation no longer triggers during programmatic navigation — CanNavigate() is now only called for user-initiated navigation (back button, tab switches), not when navigating via INavigator methods
Feature
SwitchShell — swap the entire active Shell at runtime via INavigator.SwitchShell(shell) or INavigator.SwitchShell<TShell>() (DI-resolved). Fires Navigating/Navigated events with NavigationType.SwitchShell and respects INavigationAware.OnNavigatingFrom
Feature
SwitchShell<TShell>() — generic overload resolves the Shell from the DI container, enabling constructor-injected Shell instances
Feature
NavigationType.SwitchShell — new enum value for tracking shell switches in navigation events and analytics
Feature
IDialogs interface — new dedicated dialog service with Alert, Confirm, Prompt, and ActionSheet methods, injected separately from INavigator for clean separation of concerns
Feature
Prompt dialog — display a text input dialog with customizable keyboard type, placeholder, initial value, and max length
Feature
ActionSheet dialog — display a multi-option action sheet with cancel and destructive action support
BREAKINGEnhancement
Alert and Confirm moved from INavigator to IDialogs — improves testability and follows interface segregation
  • INavigator.Alert() and INavigator.Confirm() have been removed — inject IDialogs instead
  • Replace navigator.Alert(...) with dialogs.Alert(...) and navigator.Confirm(...) with dialogs.Confirm(...)
  • Add IDialogs to your ViewModel constructor parameters where dialogs are used
  • IDialogs is automatically registered by UseShinyShell() — no additional setup required
// Before (v2.x)
public class MyViewModel(INavigator navigator)
{
await navigator.Alert("Error", "Something went wrong");
bool ok = await navigator.Confirm("Delete?", "Are you sure?");
}
// After (v3.0)
public class MyViewModel(INavigator navigator, IDialogs dialogs)
{
await dialogs.Alert("Error", "Something went wrong");
bool ok = await dialogs.Confirm("Delete?", "Are you sure?");
// New capabilities
var name = await dialogs.Prompt("Name", "Enter your name");
var choice = await dialogs.ActionSheet("Options", "Cancel", null, "Edit", "Share");
}
Feature
Configurable source generation — disable route constants via ShinyMauiShell_GenerateRouteConstants or navigation extensions via ShinyMauiShell_GenerateNavExtensions MSBuild properties (empty/missing = enabled, false = disabled)
Feature
Route-based naming — the route parameter in [ShellMap] now drives the generated constant name and navigation method name (e.g., [ShellMap<HomePage>("Dashboard")]Routes.Dashboard, NavigateToDashboard)
Feature
Invalid route diagnostic — SHINY001 compiler error when the route value is not a valid C# identifier (hyphens, spaces, leading digits)
Enhancement
AddGeneratedMaps() was always generated — even before any [ShellMap] attributes existed — so you could wire up MauiProgram.cs immediately (changed in v4.1: now requires at least one [ShellMap])
Enhancement
AddGeneratedMaps() now uses inline string literals instead of Routes.* constants, so it works correctly even when route constant generation is disabled
Enhancement
When no route is specified, the generated name falls back to the page type name without the Page suffix (e.g., [ShellMap<HomePage>]Routes.Home)
  • Route constant names may change if you specified explicit routes — e.g., Routes.Home for [ShellMap<HomePage>("Dashboard")] is now Routes.Dashboard
  • Navigation extension method names change similarly — NavigateToHome becomes NavigateToDashboard
  • Routes with invalid C# identifiers (hyphens, spaces, leading digits) now produce compile errors — rename them to valid identifiers