back_stack 0.6.1
back_stack: ^0.6.1 copied to clipboard
You own the back stack. Navigation is a list you push and pop — type-safe, observable, no route graph.
0.6.1 #
A correctness pass over the async-gating, deep-link, and page-memo edges. Still zero dependencies.
AsyncRedirect.invalidate()now cancels in-flight checks. Previously it only cleared resolved cache entries: a check already awaiting (an auth or session lookup) could still complete and apply a decision made from the very stateinvalidate()declared stale — e.g. redirecting a now-signed-in user toLogin. Each check is pinned to a cache generation; a check that resolves after aninvalidate()is dropped (not cached, not applied) and any held navigation is released, so the next navigation re-checks against fresh state.- A deep-link source that errors no longer surfaces as an unhandled async
error.
BackStackApp/BackStackTabsApphardened URI decoding, but an error frominitialLinkorlinkStreamitself went uncaught. Both are now caught and dropped — the app stays put and the stream keeps delivering later links — consistent with the existing "bad links don't crash" behavior. - Changing the
decoratorslist now invalidates the memoized pages.NavDisplayre-memoized pages on astack/builder/entries/pageBuilderchange but not on adecoratorschange, so with a stable builder existing pages kept their old decorated subtree. The decorator list now participates in memo invalidation (compared element-wise, so stable decorators keep the memo). - Raised the Flutter lower bound to
>=3.24.0to match the API surface actually used (Navigator.onDidRemovePage,PopScope.onPopInvokedWithResult); the old>=1.17.0was far too low.
(The redirect-safe pushForResult path reported alongside these was already
fixed in 0.5.0; a regression test now locks it in.)
0.6.0 #
iOS swipe-back on TransitionPage #
- The edge-swipe back gesture now works on pages with custom transitions.
TransitionPageused to build a plainPageRoute, so on iOS the standard drag-from-the-left-edge gesture did nothing — a screen pushed with.fadeor a shared-axis transition could only go back via a button. On iOS (defaultTargetPlatform), dragging from the leading edge now drives the page's owntransitionsBuilderin reverse and pops past the halfway point or on a fling, exactly likeCupertinoPageRoute— and the pop syncs theNavStackas usual. RTL layouts swipe from the right edge. - New
swipeBackparameter on everyTransitionPageconstructor to opt a page out (defaults totrue;TransitionPage.nonedefaults tofalse). The gesture also stays off forfullscreenDialogpages, non-opaquepages, the root of the stack, and any screen whosePopScope/ConfirmPopScopeblocks the pop — same rules as the framework'spopGestureEnabled. (Like the AppBar back button, it goes through theNavigator, so the programmatic-pop vetostack.popGuarddoesn't apply.) - No behavior change on Android or desktop, and zero dependencies as always.
0.5.0 #
A correctness release for everything back-button-adjacent, driven by an adversarial audit of 0.4.0. Still zero dependencies.
System back now routes innermost-first, everywhere #
- Tabs: OS back reaches the active tab's
Navigatorfirst.MultiNavStackRouterDelegate.popRoute()(andMultiNavDisplay's ownPopScope) used to pop the active tab's stack model directly, skipping the tab's routes entirely — back would pop the page under an open dialog, or leave the app with a dialog still up, and a screen'sPopScope/ConfirmPopScopeinside a tab never saw the gesture. Back now goes through the active tab's navigator viamaybePop(dialog → screen'sPopScope→ tab page → first-tab fallback → app close), tracked live viaNavigationNotificationso an open dialog is known even at a tab root. The delegate exposes its per-tabnavigatorKeys, andMultiNavDisplayacceptsnavigatorKeys:for custom Router setups. NavDisplay(nested: true)routes back through its child navigator the same way, instead of popping the stack directly — dialogs, sheets andPopScopes inside a nested display now get first refusal.
AsyncRedirect: no flash of protected content, fail closed #
- New
gates:predicate — declare what's protected. Navigation matchinggatesis now held off screen whilecheckruns: the stack stays where it is (as the docs always claimed),resolvingdrives your scrim, and the held navigation is applied — or bounced — when the check resolves. Everything not matchinggatesnavigates instantly and is never checked, so pops andpushForResultresults are unaffected. Withoutgatesthe old show-then-correct behavior remains (holding everything would swallow ordinary results); passgatesto get the hold. - A throwing
checknow fails closed: the navigation is dropped, the user stays put, and the error is reported viaFlutterError.reportError(it used to be silently allowed — an auth gate that failed open). - A
pushForResultinto an undecided gated destination resolvesnullimmediately; un-gated destinations are unaffected. Documented.
Browser back/forward restores the full typed stack #
- Each history entry now carries a full-stack snapshot
(
RouteInformation.state), so a browser back/forward hop restores the entire typed stack — in-app depth the URL can't express included — instead of rebuilding from the URL's projection. Automatic inBackStackApp/BackStackTabsAppwhenlinks(orrestoreWith) is set; custom Router setups opt in via the delegates' newstateCodec. A missing, corrupt, or URL-disagreeing snapshot falls back to the URL mapping, exactly like a deep link. The tabs snapshot covers every tab's stack plus the active tab. - Breaking (type-level only): the delegates' Router configuration type
changed from
UritoNavRouteInformation(uri + state), andNavStackRouteInformationParsernow parses that type. Apps usingBackStackApp/BackStackTabsAppare unaffected; directsetNewRoutePath(uri)callers wrap the uri:NavRouteInformation(uri). The imperative link API (handleLink/handleLinkAsync) still takes a plainUri.
Fixes #
- Tabs
observerscould never work — replaced withobserversFor. ANavigatorObserverbelongs to exactly oneNavigator(the framework asserts it), but the tabs widgets attached the same list to every tab's navigator — any non-emptyobserverswith 2+ tabs crashed in debug on the first frame.MultiNavDisplay/MultiNavStackRouterDelegate/BackStackTabsAppnow takeobserversFor: (tabIndex) => [...], called once per tab;observersremains for single-tab hosts and is rejected with a clear message otherwise. - Core: a
redirectno longer breakspushForResult._commitreconciled the redirect's result against the pre-change stack, so any attached redirect — even one that allowed the navigation unchanged — rebuilt the just-pushed entry with a fresh identity and itspushForResultawaiter resolvednull. The redirect result now reconciles against the proposed entries, keeping the pushed entry's id (and awaiter) when it's allowed through. BackStackAppwith nolinks/onLink: a runtime deep link now keeps the app where it is (as documented) instead of resetting navigation to its launch state.- Memoized pages no longer freeze inherited reads. A screen built by
builder/entriesnow rebuilds when a Theme / localization / media-query dependency changes — previously a live entry's screen was built exactly once and silently never updated.
Docs #
- README: clarified that restoration restores the stack (destinations, in order, typed); state inside a screen follows Flutter's own restoration APIs.
NavStackCodec: documented that browser back/forward rebuilds the stack from the URL viadecode, like a deep link — in-app depth beyond the URL's projection isn't preserved across a history hop.
0.4.0 #
The "handled internally" release: the app shapes and URL plumbing every real app needs, each reduced to one parameter — plus a hardening pass over the core. Still zero dependencies.
One source of truth for URLs #
NavLinks— the URL table. One entry per linkable destination declares both directions at once ('/products/:id'⇄Product), with typed parameter reads (NavMatch), query parameters,*restcatch-alls, a per-destinationparents:(the layer-vs-replace choice), andnotFound. Deep links, the web address bar, browser back/forward, shareable links (linkFor) and state restoration all derive from the same table, so the two directions can never drift apart. It's aNavStackCodec, so it plugs in anywhere one goes — usuallyBackStackApp(links: …). It never owns the stack; back_stack still has no route graph.- Round-trip drift validator. In debug builds the router verifies
encode∘decode is idempotent per URL and reports a drifted codec the moment
it's introduced — the classic hand-written
onLink/toLinkbug, caught at development time.
One-widget apps #
BackStackAppis now a true one-widget app:onLinkis optional (BackStackApp(stack: …, entries: …)is a complete app), it acceptslinks:andentries:directly, and it forwards the previously missingMaterialAppknobs (onGenerateTitle,scaffoldMessengerKey, locale callbacks, high-contrast themes,shortcuts/actions,appBuilder). Ashell:parameter wraps persistent chrome (side rail, overlays) around the navigating area, underMaterialApp. Config (onLink/toLink/builder) is read live, so hot reload and rebuilds take effect without losing navigation state.BackStackTabsApp— the tabbed app, bundled. Bottom navigation with a persistent back stack per tab used to take up to nine hand-wired objects; it's now one widget: passtabs:+destinations:(built-inNavigationBar, selection and re-tap-to-pop-to-root wired) orshell:(your own chrome). Works withlinks:— the target tab is inferred from the decoded stack's root — plusonLinkAsync,initialLink/linkStream, lazy tabs, and automatic restoration of every tab's stack + the active tab.
Async everywhere #
- Async link resolution —
onLinkAsyncon both apps (andasyncDecodeon both router delegates): await a lookup before deciding what a link shows. Race-safe by design: a newer link supersedes an in-flight one, and errors fall through to the sync mapping. - Async pop —
NavStack.popGuardAsync+tryPop(): an "are you sure?" dialog or save-before-leave call for programmatic pops, race-safe (if the stack changes while the guard is deciding, nothing is popped). AsyncRedirect.attach(stack)wiresredirect+refreshListenablein one call — forgetting the second half used to make gating silently hang.detach()undoes it;dispose()auto-detaches. Also hardened: disposing mid-check no longer crashes, and the decision cache is bounded (cacheSize) and keyed by real element-wise equality instead of a raw hash.
Full-stack restoration, automatic #
- With
links:set, the entire typed stack (and for tabs: every tab's stack + the active tab) survives process death — serialized through the same URL table, unregistered keys skipped, corrupt snapshots discarded.restoreWith:accepts aNavKeyCodecfor destinations without URLs. A cold-start deep link still wins over the snapshot unless the snapshot is just the deeper history of the same location. (RestorableBackStack/RestorableMultiNavStackremain for Router-free setups.)
Per-destination presentation #
NavEntries.on<T>(page: …)— a destination can carry its own dialog, sheet, or transition, with decorators still applied: no globalpageBuilderswitch and no mixin on domain keys. Displays and delegates accept the registry directly (entries:as the alternative tobuilder:).
Typed navigation events, ergonomics & sugar (from the previous batch) #
NavStackObserver<K>— typedonScreen/onPush/onPopin your keys.NavPath— typed URL reading/building sugar for hand-written codecs.- Ergonomic stack ops:
pushAll,popToRoot,removeWhere. BackStackApp.initialLink— cold-start link, awaited and applied through the same mapping.pushOrMoveToTopnow returns whether the stack changed — making it the double-tap-proof push (a second tap is a no-op, not a duplicate screen).replaceTop(key, result: …)completes the replaced screen'spushForResultawaiter with a value.pushForResult's result type is checked at thepop(result)call site in debug, instead of failing later as a cast error inside theawait.
Fixes & hardening #
NavDisplaypage memoization survives parent rebuilds with the documentedentries.calltear-off (function equality instead of identity), andonRemovedstill fires after a builder swap.popToRootis one commit and respectspopGuardon the current top; tab re-select uses the same semantics (one notification, protected forms stay).- A deep link targeting an out-of-range tab now falls back instead of being silently dropped.
- Applying a platform route identical to the current location is a no-op (protects restored deep stacks from being collapsed to their URL projection).
NavLinks/link decoding never throws — including trailing slashes and URLs whose percent-escapes aren't even valid UTF-8.- Shell chrome gets its own
Overlay(tooltips/menus in a bottom bar work). - Shared-axis and fade-through transitions animate at the render-object level (no per-frame widget rebuilds).
BackStack.of/MultiBackStack.of/parentOfthrow realFlutterErrors in all build modes, with a "did you forget the type argument?" hint;maybeParentOfstops walking once found; custom pages that drop the suppliedpageKeyare caught by a debug assert (they would silently desync system back).- The package archive no longer ships the 2 MB demo gif (3 MB → ~0.1 MB).
0.3.0 #
A feature drop aimed at everyday UX — all within the same few-concepts, zero-dependency model (nothing here changes how you own the stack).
AsyncRedirect— loop-proof async auth gating.redirectstays synchronous (that's what makes it loop-proof), so async gates that need to await — a permission call, a session refresh, a "does this deep-linked doc exist" check — now have a first-class home. Assigngate.calltoredirectand the gate torefreshListenable: navigating to a gated destination holds it while the check runs (gate.resolvingdrives a loading overlay), then either allows it or redirects. Decisions are cached per destination so a check runs once; callgate.invalidate()after login/logout. Built entirely on the existing sync primitives — the core is unchanged.- Material motion transitions on
TransitionPage. AddssharedAxisHorizontal/sharedAxisVertical/sharedAxisScaled(X/Y/Z) andfadeThroughalongside the existing fade/slideUp/scale. Hand-rolled, dependency-free, and the shared-axis / fade-through motions animate the outgoing screen too. Pick the motion by the relationship between screens (peer step, into-a-hierarchy, unrelated switch). - Migration guide from go_router (
doc/MIGRATING_FROM_GO_ROUTER.md) — a mapping table plus before/after for routes, navigation calls, typed arguments, redirects (incl. async), shell/tab routes, deep links and error handling, with notes on migrating gradually. - Examples:
example/lib/guarded.dart(async gating with a loading overlay) andexample/lib/motion.dart(per-destination Material motion).
0.2.8 #
- Async deep links from native. The platform's
Routeronly surfaces the launch URL and standard app links it resolves itself. Links that arrive while the app runs — a custom scheme (myapp://…), a Firebase Dynamic Link, a warmapp_linkslink — come from a native plugin as aStream<Uri>.BackStackAppnow takes an optionallinkStream: hand it that stream and every emission runs through the sameonLink, with the same never-throws fallback as a platform link. back_stack owns the subscription lifecycle and cancels it on dispose; you bring theUris from whatever plugin you use — no new dependency. NavStackRouterDelegate.handleLink(uri)/MultiNavStackRouterDelegate.handleLink(uri)— the imperative sibling ofsetNewRoutePath, for driving your own router: apply a runtime link to the stack with the identical decode-or-fallback hardening. (setNewRoutePathnow delegates to it, so platform and runtime links share one code path.)
0.2.7 #
A correctness + documentation pass. No API changes — every fix is behavior that matches what the docs already promised.
- Fix —
NavListDetailno longer crashes when a lone detail is on top on a wide screen. Previously, if the only (or first) entry was itself a detail type, it was mounted in both panes under the same per-entryGlobalKeyand threw a duplicate-key error. It now shows as the list pane with an empty detail pane. Regression-tested. - Fix —
NavEntryDecorator.onRemovedis now symmetric across the breakpoint inNavListDetail.onRemovedfires only for entries that were actually rendered (as a wide pane or a narrow page), so a wide-only middle entry that was never shown no longer gets a spuriousonRemovedwith no matchingdecorate. - Fix —
MultiNavStack.handleBackconsumes back even when apopGuardvetoes the pop. With history in the active tab, back is now treated as handled (so the app stays open) instead of surfacing the vetoed pop'sfalseto the hostPopScope— which would have closed the app. - Hardening — an empty stack is refused rather than crashing in release. A
redirect/replaceAllthat resolves to no destinations is caught by an assert in debug and, in release, declined (the current stack stays) instead of leaving theNavigatorwith an empty pages list. - Examples: adds
example/lib/tabs.dart(MultiNavStackbottom nav with per-tab history) andexample/lib/results.dart(pushForResult— await a value from a pushed screen), filling the two biggest example gaps. - Docs: README gains a table of contents and runnable snippets for results,
per-tab nav, reusing an open screen (
pushOrMoveToTop/moveToTop) and the liveBackStackInspector; corrects theBackStack.of<AppKey>type argument, the exhaustiveness claim (now that the hero leads withNavEntries), and the example README's Pokédex description.pushForResult'sObject?-cast caveat and theNavEntry.id/entriesdocs are clarified.
0.2.6 #
Convenience additions inspired by other routers — all built as plain list ops / pure functions / one widget, so the core model is unchanged (no codegen, no dependencies).
NavStack.moveToTop(test)/pushOrMoveToTop(key)— reuse a screen that's already open instead of stacking a duplicate (singleTop/clearTop).combineRedirects([...])— compose several redirect rules (auth, onboarding, flags) into oneredirect, each a pureproposed → RedirectStep(ContinueRedirect/RedirectTo(stack, stop:)). Same once-per-change, loop-proof semantics as a singleredirect.BackStackInspector<K>— a zero-dep in-app widget that lists the live stack (current entry marked), for debugging without any DevTools plumbing.
Not added, on purpose: a string query-parameter bag (typed key fields already carry a destination's arguments — a stringly-typed side channel would work against that) and a route-file code generator (back_stack stays codegen-free).
0.2.5 #
BackStackApp<K>— deep links in one function. A single widget bundles theMaterialApp.router+NavStackRouterDelegate+ parser wiring; you supply onlyonLink: (Uri) => [destinations]. OptionaltoLinkprojects the stack back onto the web address bar,onLinkFallbackhandles bad links, andrestorationScopeIdis set by default. Zero new dependencies — it drives Flutter's own Router.BackStack.parentOf<K>(context)(andmaybeParentOf) — reach the stack one level up from a nested child when parent and child share the same key type, so a child screen canBackStack.parentOf<AppKey>(context).pop()the outer flow. (Distinct key subtypes +BackStack.of<ParentKey>remain the more type-safe default.)example/lib/multi_file/now usesBackStackApp— the simplest example also demonstrates deep links (/product/7).
0.2.4 #
- Docs: the README hero snippet is corrected and no longer uses a
switch— it leads withNavEntries(..on<Home>(...),builder: entries.call) over a non-sealedAppKey, so it compiles as shown and mirrors a multi-file app. (The previous snippet declared asealedbase with noconstconstructor and inferredNavStack<Home>, so it didn't compile.) - Adds
example/lib/multi_file/— the simplest multi-file layout:app_key.dart- one file per feature (
home_feature.dart,product_feature.dart) each with its own destination, screen andregister*call, collected inmain.dart. Noswitch, no central list.
- one file per feature (
0.2.3 #
- Fix: a
pop(result)that is vetoed byguard(or collapsed to a no-op byredirect) no longer completes the destination'spushForResultfuture or returnstrue. Previously the awaiter received the result while the screen was still on top — now the result is delivered, andpopreturnstrue, only when the entry actually leaves the stack. (popGuardwas already safe.) - Docs:
NavEntryDecorator.onRemovednotes that it runs during the display's build (defer any rebuild-triggering work withaddPostFrameCallback);NavEntriesdocuments that it matches by exact runtime type, not subtype. - Adds
example/lib/modular_demo.dart(also on GitHub): three feature modules register their ownNavEntriesinto one map, and a singleNavEntryDecoratorwraps every screen and logs teardown ononRemoved.
0.2.2 #
- The main example (
example/lib/main.dart, the one shown on the package page) now demonstratesNavEntries(as the builder) andNavEntryDecorator(ascreen_viewlog + teardown hook) — so both APIs are visible on pub.dev's Example tab. No library code changed.
0.2.1 #
- Add
NavEntries<K>— a registrable destination-type → screen map (..on<Home>(...)), Compose Nav3'sentryProvider. Passbuilder: entries.calltoNavDisplay. Composes across feature files/modules instead of one bigswitch. Theswitchstays the default (compile-time exhaustive); this is the modular option. - Add
NavEntryDecorator<K>— Nav3'sNavEntryDecorator.decoratewraps every screen (providers/DI scopes/tracing; first decorator is outermost) andonRemovedfires when an entry leaves the stack (or the display is disposed) so you can tear down a Bloc/controller/scope tied to a destination. Wire via the newdecorators:onNavDisplay— also forwarded byMultiNavDisplay,NavSceneHost,NavListDetail,NavStackRouterDelegateandMultiNavStackRouterDelegate.
0.2.0 #
- Add
NavStackCodec.of(encode:, decode:, fallback:)— build a deep-link codec inline from two functions instead of subclassingNavStackCodec. - Add
NavStackCodec.fallbackFor(uri)— the stack shown when a link is malformed or unknown (defaults to decoding/; supplyfallback:or override to route to a NotFound screen). - Harden the deep-link boundary:
NavStackRouterDelegatenow decodes without ever throwing — adecodethat throws or returns empty falls back instead of crashing the app, so codecs can parse optimistically. - Fix:
pushForResultno longer hangs (and leaks its awaiter) when the push is blocked byguardor collapsed to a no-op byredirect— the future now resolvesnull, like every other way a screen can leave the stack. - Web + tabs:
MultiNavStackRouterDelegate+MultiNavStackCodecbring URL sync, deep links and OS back to aMultiNavStack(per-tab history) — the multi-tab equivalents ofNavStackRouterDelegate/NavStackCodec. - Restoration + tabs:
RestorableMultiNavStackpersists every tab's stack and the active tab across process death. Both restorable widgets now survive a corrupt/incompatible snapshot by keeping the freshly created stack instead of crashing on cold start. MultiNavDisplaygainslazy(build a tab only once first selected; defaultfalse, unchanged behavior) andobservers.observersis also now forwarded byNavSceneHost,NavListDetailandNavStackRouterDelegate— a cleanNavigatorObserverseam forscreen_viewanalytics.NavListDetailnow preserves paneStateacross the breakpoint. Rotating or resizing between the two-pane and stacked layouts keeps each screen's scroll position and controllers — every entry gets a stableGlobalKey, so Flutter reparents the live screen instead of rebuilding it.- Add
MultiBackStack.of(context)(andMultiNavStackScope) — reach theMultiNavStackhost from any tab screen to switch tabs / read the active index, without passing the host down by hand.
0.1.1 #
- Docs: the demo GIF now uses an absolute URL so it renders on the pub.dev
package page (a relative path inside a raw
<img>tag wasn't rewritten).
0.1.0 #
- Fix:
Hero(shared-element) transitions now animate inside a nestedNavDisplay— e.g. underNavListDetailorMaterialApp(home:). Each display drives its ownHeroControllervia aHeroControllerScope, so a sprite flies between screens instead of snapping. Previously only the root/Router display inherited one. Regression-tested.
0.0.1 #
- Adaptive layout:
NavListDetail(one stack → two-pane on wide, animated stack on narrow) and the generalNavSceneHost+NavSceneStrategyengine (listDetailScene,supportingPaneScene) — Compose Nav3 "scenes" over one list. - Multi-stack:
MultiNavStack+MultiNavDisplaygive a bottom-nav persistent per-tab back stack (IndexedStack), with innermost-first system back. - Custom pages:
TransitionPage(.fade/.slideUp/.scale/.none),DialogPage,SheetPage, and a per-keyNavPagemixin so a destination declares its own transition. ConfirmPopScope: async confirm-before-leave covering the Android system back.RestorableBackStack+NavKeyCodec: full-stack restoration across process death without a URL.NavDisplay.nestedflag routes the system back gesture into a nested stack.- Minimal rebuilds: pages are memoized by entry id, so one push rebuilds exactly
one screen regardless of stack depth (see
benchmark/). popGuard(sync pop veto) andrefreshListenable(re-runredirecton auth change) onNavStack.NavStack: the back stack you own. push / pop / replaceTop / replaceAll / popUntil / edit.BackStack.of(context)reaches it from a screen (auto-provided by NavDisplay).- Auth gating:
redirect(pure transform, applied once — loop-proof) andguard(veto). NavDisplay: renders the stack via the Pages API; system / predictive / hardware back sync into the list automatically (onDidRemovePage).NavKey: type-safe destinations as plain Dart objects.EquatableNavKeymixin gives them value equality so a URL re-decode reuses the live screen.- Identity & State preservation: stable unique id per entry (unique page key);
duplicate destinations are independent;
replaceAll/editreconcile so survivors keep their State and only changed screens rebuild. - Result passing:
pushForResult<T>()->Future<T?>, completed bypop([result])or null on any other removal / dispose (never hangs/leaks). - Router integration:
NavStackRouterDelegate+NavStackRouteInformationParser- a
NavStackCodec(Uri <-> List<NavKey>): web URL sync, deep links (you choose layer-vs-replace), browser/OS/predictive back, and state restoration.
- a
- Leak-safe: leaving the stack disposes the route (proven by test); suite runs
under
leak_tracker_flutter_testing. 42 tests + benchmarks, analyze clean undervery_good_analysis(strict casts/inference/raw-types). - Docs:
doc/PHILOSOPHY.mdmaps Flutter's model + the nav leak/caveat catalog.