Navigation State Restoration
Nalu can snapshot your navigation state and land the user exactly where they were after an app restart: the selected root, the pushed stack, and the intents that materialized those pages. Restore replays navigation through the normal pipeline — it never serializes page UI state.
Typical uses:
- Developer experience (the reason to turn it on today): restart the app during development and keep your place, however deep in the navigation you were.
- Android process death (production): the same mechanism, enabled deliberately.
Restore is an engine-level feature, currently verified with the Scaffold host. See it live in the Daily Helper sample (
Samples/Nalu.Maui.DailyHelper): kill the app while editing a task and relaunch — it lands back in that exact editor, with the task rehydrated throughIIntentHydratorand the "new task" draft demonstratingForgetAsync.
Enabling it
Restore is opt-in per app, configured as its own builder step next to UseNaluNavigation
(call order between the two does not matter):
.UseNaluNavigation<App>(nav => nav.AddPages())
.UseNaluNavigationRestore(restore =>
{
#if !DEBUG
restore.Enabled = false; // DEBUG-only DevEx policy, expressed app-side
#endif
restore.MaxAge = TimeSpan.FromHours(12); // optional: stale snapshots are discarded
restore.AddIntents(); // source-generated: registers every discovered intent
})
Enabled defaults to true once UseNaluNavigationRestore is called — the library cannot
see your build configuration, so a DEBUG-only policy is a one-line app-side decision (as
above).
AddIntents() is source-generated: at build time it finds every intent type your pages
and page models receive through IEnteringAware<T> / IAppearingAware<T> and registers it
with a stable id — AOT/trim-safe, always in sync with the code. Two levers tune it, both on
the intent type:
[AutoNavigationIntent(Enabled = false)] // never replay: reaching a page with this
public record CreateDraftIntent; // intent ends the restorable stack there
[AutoNavigationIntent("product-detail")] // stable wire id decoupled from the type name
public record ProductDetailIntent(string ProductId);
Intents deriving from AwaitableIntent are never registered — their completion source cannot
survive a restart. Two restorable intents sharing a short name is a compile-time error
(NALU0005) until you disambiguate with an explicit [AutoNavigationIntent("...")] id.
Intents defined in other assemblies (which
the generator does not scan) are registered manually with restore.AddIntent<T>("stable-id")
— the two styles compose freely.
Capture is automatic
Every successful navigation re-captures the state, and each page's entering intent is recorded (and serialized) at navigation time. Whether a page is restorable derives from how it was reached:
- navigated to without an intent → restorable (it needs no context to reproduce);
- navigated to with an intent whose type is registered (via the generated
AddIntents()or a manualAddIntent<T>()) → restorable, the same intent replays on restore; - navigated to with an unregistered intent (or one that fails to serialize) → the restorable stack ends at that page: its context cannot be reproduced, so neither it nor anything above it restores.
Root selection (and the root's own intent, when a root switch carried one) is captured the same way. Intents are plain objects serialized as-is (JSON by default) at navigation time — no marker interface, no live-object retention. Registration is what marks a type restorable, gives it a stable wire id, and preserves its members under trimming so the serializer round-trips it reliably. Pop intents are appearing context, not entering context: they never replace what recreates a page.
Non-serializable intent state: [JsonIgnore] + hydration
An intent may carry state that cannot (or should not) serialize — a live domain object, a
stream, a service reference. Exclude it with [JsonIgnore] and rehydrate it on restore:
public class DocumentIntent
{
public required string DocumentId { get; set; }
[JsonIgnore] // not persisted: rehydrated on restore
public Document? Document { get; set; }
}
public class HomePageModel(IDocumentRepository repository)
: IIntentHydrator<DocumentIntent>
{
public async ValueTask HydrateAsync(DocumentIntent intent)
=> intent.Document = await repository.LoadAsync(intent.DocumentId);
}
During the replay, BEFORE navigating with a restored intent, the engine walks the
already-restored stack from the top page down to the root and awaits the first page (model)
implementing IIntentHydrator<TIntent> for that intent type — so the missing state is filled
by a page that is already alive (the initialization root qualifies too), and the navigation
then delivers the finalized intent through the normal pipeline.
Snapshots are written debounced in the background (never affecting navigation) and flushed immediately when the app is backgrounded — the last reliable moment before a potential process death.
The INavigationRestore service
Inject INavigationRestore (always available; inert when restore is not enabled) for the
three explicit controls. The per-page methods deduce the page they act on — the page whose
lifecycle callback is running, or the current page otherwise:
public class CheckoutPageModel(INavigationRestore restore) : IEnteringAware
{
public ValueTask OnEnteringAsync()
// Wizard flows must never resurrect: the restorable stack ends here.
=> new(restore.ForgetAsync());
}
ForgetAsync()— removes the current page from the restoration stack: a restore lands on the page below it (and pages above cannot restore either). The exclusion is scoped to that page instance — pages below keep restoring, and it lifts when the page pops. Called on a root page it excludes that root and its whole stack (roots never pop, so it lasts for the app session) while every other root keeps tracking normally.RestoreWithIntentAsync(intent)— sets or replaces the intent replayed for the current page: swap a "create draft" intent for a "saved entity id" one once state materializes, or make a page reached with an unregistered intent restorable again by providing a registered equivalent of its context (unregistered types and serialization failures throw at this call site).TryStopRestoreAsync()— see below.
Both per-page methods persist the updated snapshot before their task completes.
What happens at boot
- The snapshot is read and deleted (a replay that crashes yields a clean next boot),
then validated: schema version, app version/build, a hash of the restorable route table
(roots, registered pages, intent ids), and
MaxAge. Any mismatch discards it. - The engine boots your configured initial destination as normal — an app's initialization root always runs first, doing whatever essential work it does.
- When the initial page's first
OnAppearingAsynccompletes, the replay executes: one navigation selecting the captured root (with its intent), then the captured pushes — chunked so every captured intent rides its own navigation's target through the normal pipeline. Animations, lifecycle and intent delivery are exactly the live ones. - The snapshot is re-persisted (capture is automatic, so the replay itself re-recorded it).
Everything is fail-open: an unknown page segment or intent id truncates the restored stack at that frame; any error discards the snapshot and boots the default destination. Restore can never brick startup. It also runs once per app launch — a host created later in the same process (logout/login swap) boots normally.
The suppression window and TryStopRestoreAsync
While a restore is pending, navigations not issued by the replay are ignored (they
return false and raise the NavigationIgnored lifecycle event). This is deterministic, not
a race: each replay step is enqueued through the dispatcher, so an auto-navigation a restored
page fires from its lifecycle (the usual DispatchAsync pattern) always drains before the
next replay step — inside the window. The window lifts just before the last restored
destination, so the page the user actually was on keeps its right to auto-navigate.
The escape hatch is for flows that must legitimately win — authentication above all:
public async Task OnInitializationCompletedAsync()
{
if (!user.IsAuthenticated)
{
// This restore shall not happen: drop it, lift the suppression, navigate freely.
await restore.TryStopRestoreAsync();
await navigation.GoToAsync(Navigation.Absolute().Root<LoginPageModel>());
return;
}
// Authenticated and a restore is pending: do nothing — the replay lands the user back.
}
TryStopRestoreAsync() returns true when there was a pending (or in-flight) restore to
stop; the discarded snapshot is replaced by a fresh capture of wherever the app goes next.
Recipe: handling deep links
Nalu deliberately ships no URI routing — scheme design, auth gates, missing targets and
notification payload shapes are app-specific. What the library guarantees is the pair of
primitives a deep-link handler composes: typed absolute navigation, and
TryStopRestoreAsync() so a link and a pending boot-time restore never fight over the first
navigation (the link must win — otherwise the replay ignores it, or lands the user somewhere
else first).
public sealed class DeepLinkHandler(INavigationService navigation, INavigationRestore restore)
{
public async Task HandleAsync(Uri uri)
{
// Your parsing, your rules: auth gates, tenant checks, deleted targets…
if (TryParseOrder(uri, out var orderId))
{
// A pending restore must not race the link: drop it and lift the
// navigation-suppression window. Inert (false) when restore is off,
// or already replayed, or on a warm start — safe to call unconditionally.
await restore.TryStopRestoreAsync();
await navigation.GoToAsync(
Navigation.Absolute()
.Root<OrdersPageModel>()
.Push<OrderDetailPageModel>()
.WithIntent(new OrderIntent(orderId))
);
}
}
}
Funnel every platform entry point into that one handler: Application.OnAppLinkRequestReceived
for universal/app links, the platform activity/scene callbacks for custom schemes and
notification taps. On a cold start the URI typically arrives before the engine finished
booting — stash it and invoke the handler from your initialization flow (the same place the
authentication redirect above lives, after the initial page's first appearing), not from the
platform callback directly. On a warm start the handler can run immediately: there is no
pending restore, TryStopRestoreAsync() no-ops, and the absolute navigation simply happens.
The captured snapshot updates automatically as the link navigation lands, so killing the app afterwards restores the deep-linked stack like any other — including the typed intent, when its type is registered for restore.
Customization
IIntentSerializer— the wire format. The default is System.Text.Json reflection, trim-safe thanks to theAddIntent<T>member preservation; for NativeAOT supply a source-generated context viarestore.IntentSerializerContext = MyJsonContext.Default(every registered intent type must be included), or replace the service in DI entirely.INavigationRestoreStore— persistence. The default is a JSON file in the app cache directory (which has exactly the "safe to delete" semantics restore data wants); replace it in DI for custom locations.- Intent type ids — the id stored in the snapshot defaults to the type's short name,
collision-checked; override it with
[AutoNavigationIntent("stable-name")](picked up by the generatedAddIntents()) or a manualAddIntent<T>("stable-name"). Never an assembly-qualified name: renames invalidate old snapshots instead of deserializing the wrong thing.
What does NOT restore
- View state (scroll offsets, entry text): restore replays navigation; page state is the page's concern.
- Forgotten pages (
ForgetAsync), pages reached with unregistered intents, and anything above them in the stack. - Non-current roots' stacks: the current root/stack only — other tabs restart fresh.
- Transient overlays (popups/sheets/flyouts). Modal pages restore as part of the stack — their presentation mode re-resolves from the recreated page.