Navigation
Typed navigation at a glance
// the whole app entry point (since 0.8.1): lib/main.dart runs the main() fsp generates
Future<void> main() => AppMain.run();
// or by hand
MaterialApp.router(routerConfig: AppRoutes.router());
// or inside an existing GoRouter (brownfield)
GoRouter(routes: [...legacyRoutes, ...AppRoutes.mount(at: '/shop')]);
// typed navigation, generated from the tree
ProductRoute(id: 42).go(context);
const SearchRoute(q: 'ap', page: 2).go(context); // → /search?q=ap&page=2
ProductRoute(id: 42).go(context, locale: 'fr'); // → /produits/42 (`location` stays /products/42)
// each route's data.dart, as a Riverpod provider
ref.watch(ProductRoute.data(42));
ProductRoute.watch(ref, id: 42); // the same, typed: AsyncValue<Product>
await ProductRoute.read(ref, id: 42); // Future<Product>
final warm = ProductRoute(id: 42).prefetch(ref); // start loading before navigating; warm.close() lets go
await const ProductsRoute().refresh(ref);
// each route's action.dart: a write with pending and error state, then the data it made stale reloads
await ProductRoute.submit(ref, id: 42, input: refund); // Future<Refund>, throws what it threw
final refund = ProductRoute.useAction(ref, id: 42); // for build(): .state is AsyncValue<Refund?>
// from a location to what it reads (an app's own prefetch queue, tests): no guard runs, no widget is built
AppRoutes.dataAt(Uri.parse('/products/42')); // [ProductRoute.data(42)]; null when no route fits
AppRoutes.match(Uri.parse('/products/42')); // RouteMatch: the RouteInfo, the parsed params, the dataThe URL as state: of and copyWith
Since 0.5.0. A filtered, sorted, paginated list that keeps its state in the query is a link someone can send, and back and forward move between its views. Every typed route has two ways in and one way to change a part of it:
final route = SearchRoute.of(context); // the typed route at the current location
final maybe = SearchRoute.maybeOf(context); // null instead of throwing
route.copyWith(page: (route.page ?? 1) + 1).go(context); // a history entry
route.copyWith(sort: Sort.name, page: null).go(context); // null clears a query parameter
SearchRoute(q: 'ap').copyWith(page: 2).location; // '/search?q=ap&page=2': a value, no widgetXRoute.of(context)parses the location the widget belongs to with the parsersAppRoutes.matchuses (it callsAppRoutes.matchUrl): the mount point is taken off, a localized spelling is the route it spells, and the case setting, enums, lists and catch-alls read as they do for the page. It throws aStateErrornaming the location when it is another route;XRoute.maybeOf(context)returnsnull. Use it in a widget below the page that wasn't handed the parameters; the page itself has them as constructor arguments and rebuilds when they change. (Aconstroute staysconst:ofandcopyWithare members, not constructor parameters.)- Which location.
GoRouterState.of(context)'s: the route around the widget, not whichever page is on top. A page reads the part of the URL its own route matched, with the URL's query, so/products/42?ref=mailleaves the page of/productsbelow it readingProductsRoute(ref: 'mail'); a page under a pushed one and a tab that is built but not shown (preload) read their own. A layout (a shell, a tab layout) is above any one page and reads the whole location. Outside any route (MaterialApp(home: ...))ofthrows go_router'sGoErrorandmaybeOfreturnsnull. A widget that calls it rebuilds when its route's state changes, as withGoRouterState.of; call it inbuildor in a handler of a mounted widget. copyWithtakes every segment and every query parameter of the route, by name and with the field's own type.- A parameter left out keeps its value;
nullclears an optional query parameter (page: nullleaves?page=out of the location). It tellsnullfrom omitted because it is a getter of a function type (why). - A segment, and a
List(a repeated query parameter or a catch-all), is not nullable, socopyWith(id: null)doesn't compile: clear a list with an empty one (tags: const []). - It returns the same route class, so
.go,.push,.replaceand.locationfollow. - What isn't a parameter isn't carried: give an
extraagain togo(context, extra: ...), and choose the localized spelling where the route is used (go(context, locale: 'de')).
- A parameter left out keeps its value;
go,push,replace.gofollows the URL: on the web it adds a history entry, and back and forward restore each view.replace(since 0.6.0) shows its location in the address bar. Over a page of the declarative stack (one reached bygo, a link or the address bar) it replaces the history entry instead of adding one, so it is the one for a change that shouldn't pile up in the back stack (typing in a search box): it isgoinside Flutter'sRouter.neglect, the stack becomes the one the new location has by itself, and a page with the same path template keeps its state.- Over a
pushed pagereplaceis go_router's own: it swaps that page, keeps the stack below it and the page's state, and adds a history entry, aspushdoes. go_router tells the browser about each new location as a new entry, and onlyRouter.neglect(orrouterNeglect: true) asks it to replace one, which the pushed case does not use. So eachreplaceover a pushed page is one more step for the back button, and the browser's back brings the earlier query or parameter back. For a search box on a pushed page that step is rarely wanted: keep the query in the page's own state, or put the search on a page reached withgo, wherereplacereplaces the entry. On a guarded pushed page the browser's back to an earlier location of the page is a move (since 0.21.1): an empty box until the guard answers, then the page is built from scratch and its state is lost. pushstays out of the address bar and the history unlesspush_updates_url: trueis set in the pubspec (since 0.6.0).
pushReplacement(since 0.7.0) is go_router's own, typed likepush: the page on top leaves and a new one is pushed, with a new page key, and the future completes with what that page pops with. Use it wherereplaceis wrong because the page's state or transition must not carry over: a sheet that hands over to a full page, or the reverse. The replaced page's own future never completes, and when it was the only page, neither does this one (go_router's behaviour). Both takelocale:, andextra:where the route has one.- Reserved names.
of,maybeOfandcopyWithcan't be segment or query names (see Typed helpers on the route). - Everything here is synchronous and uses no timer or microtask.
- The page's own state. A
copyWiththat only changes query parameters keeps the page and its widget state undernever(the default) andonSegments, and starts the page again underonLocation. To keep that state acrosspage: 2and still start fresh for another product, sayRemount.onSegments: see Remounting a page.
Remounting a page: remount
Since 0.6.0. go_router keys a page by its path template, so /products/1 to /products/2 is the same page: its widget is built again with the new id, but its State (a scroll position, a text field, a hook's useState) lives on. Some apps want that, others want a fresh page, so it is a setting. Remount is an enum fespalier exports, with three values:
| Value | The page starts again (a fresh state) when | It keeps its state when |
|---|---|---|
never (the default) | never | anything changes in the URL |
onSegments | the value of a segment changes (/products/1 to /2) | only the query changes (?page=2) |
onLocation | anything changes in the location, the query included | the location is the same |
onSegments is the one that fits the URL as state: the page keeps its state across XRoute.of(context).copyWith(page: 2), and starts again on another product.
Where to say it. For the whole app, in the pubspec's fespalier: section (never, on_segments or on_location):
fespalier:
remount: on_segmentsFor a folder, in its route.dart, which covers that folder and everything below it:
// lib/app/products/route.dart
import 'package:fespalier/fespalier.dart';
const remount = Remount.onSegments;- It must be
const, one ofRemount.never,Remount.onSegmentsandRemount.onLocationwritten out (an import prefix,fsp.Remount.onSegments, is fine), and declared once. Anything else is an error with a code frame, and so is a pubspec value that isn't one of the three (the message lists them). The other rules ofroute.dartconstants are in Configuration; a folder can sayRemount.neverto go back to the default under a parent that remounts. fsp routestags such a pageremount, and--jsonsays which in aremountkey.
What it keys. Only the page of a route: the generated code gives it a key that changes with the URL, in place of go_router's state.pageKey.
onSegments: the template plus the values of the route's own path parameters, those of the folders above it included (/teams/:teamId/members/:memberstarts again when either changes), as the URL spells them. The static parts of the path (their case, a localized spelling) are not in it. A route with no segment has nothing to watch, and is generated as if it werenever.onLocation: the template, the path matched down to this route and the whole query. The fragment (#top) is not in it. A page below which another is pushed (/orders/1under/orders/1/refund) keeps its key and state, because the path is the route's own, not the whole location's.
A layout is not remounted, whatever its folder says: its page is keyed by its folder so that it and its sections outlive a change of the URL (the pages inside it start again by their own remount). Give a widget in a layout a ValueKey of your own if it should start again.
A new key is a new page. go_router treats a page with another key as another page: the navigator replaces the old page with the new one, so the page's transition may play, and the old page leaves with its own.
- How it looks is the page's
transition.dart(orpresent.dart), which receives the key as itsLocalKey keyparameter. Pass it on to thePageyou build, asTransitions.fadedoes. - A
transition.dartthat takes no key can't give the new page one, soremounthas nothing to act on there, and the generator warns about it. - Without a
transition.dartthe page is a Material page, or a Cupertino one inside aCupertinoApp, built like a layout's page is (remountPage). - Use
Transitions.nonefor a page that should start again without animating.
Not what it is for. It restarts the page's widgets, not its data: a data.dart provider is keyed by the segments and the query already, and reloads (see Retries and reloads) whether the page remounts or not. And it does not change which route matches or what XRoute.of(context) reads.
Typed extra
go_router can carry an object with a navigation, context.go(location, extra: product), that isn't part of the URL. A page asks for it with a parameter called extra, and the typed route takes it as an optional argument:
// notes/$id/page.dart
class NotePage extends StatelessWidget {
const NotePage({super.key, required this.id, this.extra});
final int id;
final Note? extra; // nullable: the URL alone can't produce it
…
}
NoteRoute(id: 3).go(context, extra: note); // also push<T>(…, extra:), pushReplacement<T>(…, extra:) and replace(…, extra:)
NoteRoute(id: 3).go(context, extra: 'oops'); // compile error: a String isn't a Note?The parameter must be nullable (Note?, Object? or dynamic; anything else is an error at that parameter). The object isn't in the URL, so a deep link, a page opened from context.go('/notes/3') and (without an extraCodec) a reload or a restored state all get null: build the page from the URL (id) and treat extra as a shortcut, not the source of truth. An object of the wrong type passed around the typed route (a plain context.go(location, extra: …)) is an assertion error in debug builds and reads as null in release builds.
extra is a reserved name: a segment can't be called extra, and a query parameter of that name is the extra, not ?extra=. The generated file names the type for the typed arguments by importing it, showing that name, from each of the file's own imports (a library that doesn't export it is ignored; a type declared in the file itself, or under an import prefix, is found too), so the type must be reachable from those imports. The built-in dart:core types need nothing.
Layouts, guards and redirects take it too. A layout.dart, a guard.dart or a redirect.dart can ask for extra the same way (a nullable type; a guard and a redirect take it as a named parameter). Each gets the extra of the location it is at now, state.extra:
// notes/layout.dart: the frame above every note
class NotesLayout extends StatelessWidget {
const NotesLayout({super.key, required this.child, this.extra});
final Widget child;
final Note? extra;
…
}
// notes/$id/guard.dart: a draft isn't shown yet
GuardResult guard(Ref ref, {Note? extra}) =>
extra?.title == 'draft' ? const HomeRoute().location : null;A layout or a guard sees the extra of every route it covers, so its type has to fit theirs, or it's an error at its parameter, with a code frame that lists the routes:
A guard or layout takes
Object?(ordynamic) to accept anything, or the type of the routes it covers (Note?above pages that takeNote?). Nullability aside, the names have to match; since 0.21.0 atypedefcounts as the type it names (NoteRef? extrawithtypedef NoteRef = Note;fits a page that takesNote?). A route that takes no extra puts no condition on it.So a layout above routes with different extra types must take
Object?; otherwise the routes that don't fit are listed:texterror: `extra` is `Note?` here, but the routes it covers take other types: `/notes/:id/print` (notes/$id/print/page.dart takes `Receipt?`); a layout sees the extra of every route it covers, so declare it as `Object?` to accept any of them, or as their type when they share one ┌─ lib/app/notes/layout.dart:3:53A page's or redirect's own type decides for a route; on a route without one, the guards and layouts above it must agree with each other. A layout isn't compared with a
redirect.dartroute below it, which never shows it.A route that takes no extra of its own gets the type its guards and layouts agree on, so
NoteRoute(...).go(context, extra: note)is typed even if the page ignores it.Object?says nothing about a type: it adds no typed argument.A wrong type never crashes them. A layout, a guard or a redirect sees extras meant for other routes, so an object that isn't a
Notereads asnull(extraOrNull), and so does an extra that isn't there. Only a page asserts, as above.
Restoring extra on the web
go_router keeps a navigation's extra for the browser's history and for state restoration, but can only save JSON. Without help, an object with a toJson() comes back as the Map that jsonEncode made of it, and any other object is dropped (go_router logs a warning). Neither is your type, so a page that asks for a Note? gets null in release builds and, for the Map, an assertion in debug builds. To get the object back, give the router an extraCodec.
Put a top-level extraCodec in lib/app/extra_codec.dart, at the root of the app folder (a const, a final or a getter; fsp only looks for the name). The generated AppRoutes.router() passes it as GoRouter.routingConfig(extraCodec: …) (since 0.19.0; GoRouter(extraCodec: …) before):
// lib/app/extra_codec.dart
import 'package:fespalier/fespalier.dart';
final extraCodec = ExtraCodec({
Note: (toJson: (Note n) => n.toJson(), fromJson: Note.fromJson),
Mode: (toJson: (Mode m) => m.name, fromJson: Mode.values.byName),
});ExtraCodec takes each type and how it becomes JSON and back (annotate the parameter of toJson; a constructor tear-off does for fromJson), and saves an object under its type's name. null, strings, numbers, booleans and plain JSON lists and maps need no entry.
It never breaks navigation: an object whose type isn't registered is saved as null (a debug build says so, below), and saved data that no longer reads (the type was removed, or fromJson throws) comes back as null, so a page falls back to what the URL says. Pass strict: true to throw instead, in a test that checks you registered every type.
ExtraCodec({...})looks the type up by its exact runtime type: register each subclass of a sealed class. That never matches a class freezed makes fromconst factory Note(...) = _Note: the object is a private_Note, not aNote, so aNoteentry finds nothing and the extra is saved asnull. UseExtraCodec.offor those (since 0.17.0).- The name is
Type.toString(), which a release build for the web minifies (stable within a build, different in the next). To keep saved data readable across deployments, name the types:ExtraCodec({...}, names: {Note: 'note'}), orExtraType<Note>(..., name: 'note'). - Write your own
Codec<Object?, Object?>instead if you like (const extraCodec = MyCodec();). AppRoutes.mount()doesn't take it: a router you build yourself passesextraCodec: extraCodec(imported from that file) toGoRouter. A router restores only what it is given arestorationScopeIdfor (see State restoration).extra_codec.dartin a subfolder is a warning, and a file without anextraCodecis an error.
ExtraCodec.of (since 0.17.0) takes a list of ExtraType<T> and matches an object with is T, the first one in the order written, so a freezed class, a sealed family or a class with private subclasses needs one entry, and the most specific type goes first:
// lib/app/extra_codec.dart
final extraCodec = ExtraCodec.of([
ExtraType<Note>(toJson: (n) => n.toJson(), fromJson: Note.fromJson), // a freezed `_Note` is a Note
ExtraType<Shape>(toJson: (s) => s.toJson(), fromJson: Shape.fromJson, name: 'shape'),
]);toJson's parameter is a T, so a closure needs no annotation; name: is the name in the saved data (default T.toString(), minified on the web, as above). With Shape before Circle, a Circle is saved as a Shape. Nothing else differs: the same strict, the same null for what it can't save or read, and two entries with one name are an error. In a debug build, an object that no entry takes prints one line, once per type, that says what to register (a private type such as _Note gets the freezed hint), and is still saved as null; a release build prints nothing and behaves as before.
The root navigator (navigator.dart)
A route's URL and the navigator it renders on are two decisions. A tab layout puts every route in its folder on a tab's navigator, under the navigation bar. navigator.dart puts a folder on the root navigator instead, above every layout and tab bar, without moving its URL:
// lib/app/(tabs)/profile/edit/navigator.dart
const navigator = RouteNavigator.root;/profile/edit is still under /profile (a deep link builds the Profile tab beneath it, and back returns to it, with its state), and the page covers the whole screen. The declaration applies to its folder's routes and to every folder below it, and the nearest one wins, like transition.dart; a page-less (group) folder can hold it too, for the routes inside. fsp gen emits parentNavigatorKey: rootNavigatorKey on the route and on all its descendants (gorouter puts a route on its enclosing shell's navigator unless told otherwise, so a child pushed from the page would land _under it), and the route table marks them (root).
The generated file owns the key:
AppRoutes.rootNavigatorKeyis aGlobalKey<NavigatorState>the app can read: the lastrouter()ormount()call's (a call given no key makes a fresh one rather than keeping an earlier call's, since 0.5.0).AppRoutes.router(navigatorKey: …)uses one you supply.AppRoutes.mount(at:, navigatorKey: …)takes the hostGoRouter's own key, since aparentNavigatorKeymust name an ancestor navigator.
Rules:
fspreads the file from the source, liketabs: aconst navigatorthat isRouteNavigator.rootorRouteNavigator.shell, spelled out; anything else is an error at it.- A layout is a navigator of its own. A
layout.dartbelow a root folder becomes aShellRoute(parentNavigatorKey: rootNavigatorKey, …)(or theStatefulShellRoute); the routes inside it sit on its own navigator, since go_router doesn't allow a key other than the shell's there. Below a layout nothing is inherited, andRouteNavigator.shellis how a folder says so explicitly. Below a root route with no layout in between,.shellis an error: go_router only lets a descendant use the root navigator or a navigator above it. - A root route can't be a direct child of a shell. go_router lifts a route out of its shell only from below another route, so a root route that is the first route of a tab, or sits beside others directly in a layout, is an error (put it below a
page.dartthat stays in the layout, or move its folder out of the layout's folder). - The typed route is unchanged:
EditProfileRoute().push(context)and.go(context)as before.
present.dart: a page of your own
present.dart builds this route's own Page. It is for a sheet (or a dialog, or any page class the app owns) with a URL: /products/:id/buy opens a sheet over /products/:id, from a link or a deep link.
// lib/app/products/$productId/buy/present.dart
Page<void> present(LocalKey key, Widget child) => SheetPage(key: key, child: child);It is bound like transition.dart (key, child, state), and what it returns is used verbatim: fespalier adds no scrim, handle or shape, and ships no sheet widget. It does not name the page either: pass name: Transitions.pageName (since 0.9.0, the route's pattern) for an observer to see it. Unlike transition.dart:
- it applies to its own folder only: a folder below keeps the nearest
transition.dartfor its own page; - it puts the route on the root navigator, over a tab bar and any
layout.dart, and its descendants too (thenavigator.dartrules, so a child of a sheet renders above it, never under it, and go_router never builds the shell twice). Anavigator.dartin the same folder overrides that (RouteNavigator.shellkeeps a sheet in a tab); - it needs a
page.dart(a warning and no effect otherwise), and, to have a parent underneath on a deep link, the sheet's folder should sit below the parent page's folder.
The route table marks it (present, root), and the manifest's presentation is RoutePresentation.custom (fespalier can't know it is a sheet: say so in a meta.dart if you want to).
Leaving a page: leave.dart
Since 0.11.0, a leave.dart is asked before its folder's page goes: a form with unsaved changes, a call in progress. It returns true to let the page go and false to keep it, and it asks however the app does (no dialog is assumed). It is go_router's GoRoute.onExit, written for you, with a back gesture that asks too.
// lib/app/orders/$id/edit/leave.dart
import 'package:fespalier/fespalier.dart';
import 'package:flutter/material.dart';
/// Asked before /orders/:id/edit goes. True lets it go; false keeps it.
LeaveResult leave(BuildContext context, Ref ref, {required int id, required PageLeave page}) async {
if (!page.isDirty) return true; // stays sync when you return a bool directly
final ok = await showModalBottomSheet<bool>(context: context, builder: (c) => DiscardSheet());
return ok ?? false;
}The function. leave, a public top-level function that returns LeaveResult (FutureOr<bool>, so the file needs no dart:async); FutureOr<bool>, Future<bool> and bool are accepted too. Other functions in the file are helpers and are ignored.
The parameters. Positional, each optional and in this order: BuildContext context, then Ref ref. The context is the root navigator's (go_router hands it to onExit, not the page's), so showModalBottomSheet(context: context) opens above every layout and tab bar. The Ref is a throwaway provider's, kept open until the answer is in, so it is still valid after an await: ref.read what you need, and do not ref.watch (a prompt must not run twice, so the first run's answer stands, and a watched provider that changes disposes that run's Ref). Named, bound like a guard's: the folder's segments and those above it, typed (required int id); query parameters, optional and nullable (they belong to the folder's route); Uri uri; extra, typed, as a guard takes it; and PageLeave page, by type and name. WidgetRef, ProviderContainer and TypedLocation are errors.
What leave() learns: PageLeave. page.state is the GoRouterState of the page that is going. page.isDirty says whether anything on the page would be lost (any LeaveSource it registered is dirty; a useForm of fespalier_forms registers its form by itself, nothing else does, so a page that registers none is always clean), page.canKeep whether one can save its input as a draft, page.keep() makes each save its draft and page.discard() makes each drop it. A page with nothing registered is clean and cannot keep.
A folder's own page only. A leave.dart is not inherited: it applies to its folder's GoRoute and nothing else, because go_router already asks a parent's onExit when the parent's own match exits:
/orders/1/editto/orders/1askseditonly;/orders/1/editto/homeasksedit, then$id;- a
nest = falsesibling leaves its parent's match, so the parent'sleave.dartis asked too.
The folder needs a page.dart: a layout's shell has no onExit in go_router, so a leave.dart beside a layout.dart alone is an error (put one beside each page that needs it), and so is one in a redirect.dart folder, which never stays on screen.
The one page-less exception: a flow section (since 0.11.0). A section whose action.dart has const steps is a multi-page form, and its leave.dart goes beside the layout.dart with no page.dart: it is the onExit of each step's GoRoute, so the question is asked once for the whole flow. The generated leaveExit gets within: (the section's path), and a navigation whose destination is still inside that section (a step to the next, back, goTo, the browser's back) passes without calling leave(); leaving the section asks. Such a leave() takes no segment, query parameter or extra (it is asked on every step), but Uri uri and PageLeave page. A page-less folder that is no flow keeps the error.
What is asked. (leave.dart is imported eagerly, never deferred, so whatever it imports, a deferred page's own library included, is part of the main chunk: keep it to what the question needs.)
- Asked:
go,replace,pushReplacement,pop,context.popandNavigator.popof the page;- Android back (through
PopScopeandGoRouter.pop, or go_router's root fallback, wheretruecloses the app); - the web's back and forward (refused: the address bar is reset to the page, a new history entry, forward history lost);
- a guard's refresh or redirect, including a sign-out redirect: check auth in
leave(), or wrap sign-out inleaveWithoutAsking. A guard's re-run of a pushed page that moves the page (a redirect, or an error view) asks too (since 0.21.0), whatever the destination: the flow section's exemption below (a destination still inside the section passes) is for a navigation the app made, a base page's redirect included, and does not apply to a re-run.leaveWithoutAskinglets it through. Over a base page that is under a redirect, a change that only the pushed page's guard notices is arefresh()first, which go_router restores the pushed page for with a new entry and asks it: a dirty page can be asked twice, once for the refresh and once for the re-run (see Guards, "A dirty page can be asked twice"); - go_router's
onEnter(the adapters', since 0.11.0) and guards run first at parse time;onExitruns after, in the delegate; - leaving a tab layout, for the active tab's pages only.
- Not asked:
- a tab switch (fespalier parks; go_router would ask), unless a route it reaches that the page is not already under has a
redirect:of its own (aguard.dartin that tab's own folder): that redirect may take the navigation out of the tab layout, so the page is asked. A guard of the tabs' folder, or above it, is emitted on the tab shell (since 0.11.0), so it never makes a switch ask. The price: if such a guard's answer changes at the switch itself, without a refresh (it reads state that is not reactive, such as an expired token, or decides on theuri), and it redirects out of the tab layout, the page is dropped without being asked. A hand-written top-levelredirect:on the router cannot be seen either; - a parked tab's pages when the whole layout leaves (use drafts);
- a query-only change (
copyWith(page: 2)), norreplaceof a pushed page with itself at another query; - a dialog or sheet (a pageless route) on top;
refresh()with the same match list;- a guard's re-run of a pushed page whose guards pass again (since 0.21.0): it replaces the page with its own location, and the page stays;
- a guard's re-run of a pushed page whose guards pass again while the person's own question about the page is still open (since 0.21.0): it is refused, replaces nothing and asks nothing, so the answer to the question still finds the page it was asked about (a re-run that moves the page joins that question instead; a press of the person's, or an app's own
pop(), is asked as usual whenever it comes); - a redirect.dart route;
- layouts;
- an imperative
Navigator.pushor replace outside go_router; - process kill, hot restart,
router.dispose(); - the page's own
PopScope(canPop: false), which wins for system back.
- a tab switch (fespalier parks; go_router would ask), unless a route it reaches that the page is not already under has a
- Known gap: on go_router before 17.4 (Flutter below 3.38 resolves 17.2.3 at most), popping a whole
ShellRoutepage off the root navigator skips the leaf'sleave()(go_router fixed it in 17.4.0).
/c/1 to /c/2 is asked, whatever remount says: it is another page instance. A query-only change is the same one.
The answer.
- Synchronous stays synchronous. A
leave()that returns aboolmakes noFutureand no microtask of its own. - One prompt at a time. While a prompt for a page is open, a second ask of the same page (a double tap on back, a second
go) waits for it andleave()does not run again. The first answer acts on the page; a second ask answerstrueonly if the page is still there after that, so a double pop completes once and a back that joined ago's prompt never closes the app. Aleave()whoseFuturenever completes holds every later ask of that page: complete it. A newergothat joins the prompt of an earlier one is refused once the earlier one has taken the page away (the first navigation wins). - Errors fail open. A
leave()that throws, now or in itsFuture, is reported withFlutterError.reportError(libraryfespalier, contextwhile running leave() of orders/$id/edit/leave.dart) and the page goes: a brokenleave()never traps the user. In a widget test that fails the test. - A segment that does not parse shows not-found, so there is nothing to ask and the page goes; the not-found page is not wrapped, so it keeps the iOS swipe.
- A pop is judged by where the router is going. While an asynchronously guarded
gois still being parsed, a pop of a page in a tab is compared with that destination and may be taken for a tab switch.
Skipping the question: leaveWithoutAsking.
A navigation the user has already decided, such as signing out, should not ask:
await leaveWithoutAsking(router, () => ref.read(auth).signOut());navigate may be asynchronous: no page asks while it runs and while the Future it returns is pending. leaveWithoutAsking always returns a Future, which completes when its window has closed: it waits for what fespalier's own guards settle after navigate (the container runs what was scheduled, an asynchronous guard still being evaluated is awaited) and lets through the refresh a guard asks for in that time, so signing out a session that a guard.dart watches ends at the login page without a question. That pass is kept until the router commits what the refresh set going, however long a redirect on the way (an async redirect.dart at the destination, say) takes, and ends early at the next navigation of any other kind; a guard that never answers therefore keeps it until the next navigation, and a pop in that time is not asked. If navigate requested a navigation that the router has not committed by then, that one is let through too, while the route information is still the one it left, until the first commit (a one-shot listener). It never covers a pop after the window, nor a navigate that requests nothing or a request that commits nothing (a go to where you are): a sign-out that fails leaves every page asking again. A hand-written top-level redirect: that settles later is not waited for. GoRouter.of(context) gives the router.
The back gestures.
Every generated page of a folder with a leave.dart is wrapped in leaveScope, which holds the page's sources and a PopScope. When the page's route can pop (a pushed page), the PopScope lets a pop through only if the page has at least one source and every source is clean; otherwise the back is turned into GoRouter.pop, which asks leave(). So:
- The iOS edge swipe is off while the page has no source or a source is dirty, because the gesture animates the page away before go_router can refuse. It works when every source is clean, and the pop then asks
leave()like any other. - Android's predictive back shows no preview while the
PopScopeblocks. - Android back always goes through
leave(): through thePopScopeon a pushed page, and, on the bottom page of a navigator, through go_router's own fallback, wheretruelets the system close the app andfalsekeeps it.
What a page registers: LeaveSource.
LeaveSource is what a form, or anything else that holds input, registers to be asked about: isDirty, canKeep, keep(), discard(), and it is a Listenable (the PopScope is rebuilt when it notifies). A widget under the page registers it in the page's scope, safely during build:
final unregister = LeaveScope.maybeOf(context)?.register(source); // null in a page without a leave.dartCore knows nothing of forms: it is the fespalier_forms package that makes its forms sources (since 0.11.0): a useForm under a page with a leave.dart registers its form, and leaveIfClean(context, ref, page) is the whole leave() that asks in a bottom sheet and keeps or drops the draft (see Leaving with unsaved changes). Anything else that holds input registers its own, as examples/features does for its new-doc page.
A source with somewhere to go back to inside the page (a step of a multi-page form) can take the system back itself: LeaveScope.maybeOf(context)?.onBack(() => handled) registers a handler that runs, newest first, on Android's back and Navigator.maybePop; the first that returns true has handled it, and the page is not popped and leave() is not asked. It is consulted through the page's PopScope, which blocks while a handler is registered, so the iOS swipe is off (since 0.11.0 that holds on the first page of a navigator too, which is where a step of a flow is: the only page of its shell's navigator). Register a handler on such a page only while it can handle the back: when every handler declines, the back pops the page through go_router (which asks leave()) if the router can pop, and otherwise ends the app with SystemNavigator.pop, without asking. With none registered, go_router's own fallback asks leave() on the first page.
Diagnostics and the scaffold.
fsp new 'orders/[id]/edit' --leave writes the file. Each error says what to change: the function is missing or returns the wrong type, the folder has no page.dart or has a redirect.dart, a WidgetRef or ProviderContainer is taken, the positional parameters are not context then ref, or PageLeave is not called page.
Links: RouteLink
RouteLink (since 0.5.0) is a link to a route: a real <a href> on the web, a plain widget everywhere else, and a click that goes through go_router either way.
RouteLink(
to: ProductRoute(id: p.id), // any typed route; or `uri: Uri.parse('/products/2')`
preload: Preload.intent, // none (default) | intent | visible
onPreload: (context) => ..., // runs when the preload starts: what else the page needs (since 0.9.0)
method: LinkMethod.go, // go (default) | push | replace
builder: (context, follow) => ListTile(title: Text(p.name), onTap: follow),
)builder gets follow, which navigates; give it to the child's onTap or onPressed. The child is exposed to accessibility services as a link with its URL, and const RouteLink(...) works when the route and the builder are constant.
- On the web it is built on
url_launcher'sLink, which lays an invisible anchor over the child. The browser shows the URL in its status bar, the context menu offers "open in a new tab", and a middle click, or a click with Ctrl, Cmd, Shift or Alt, opens it in a new tab or window.- A plain click or a keyboard activation never reaches the anchor:
followcallsGoRouter.go,pushorreplace(themethod), the page doesn't reload, and the anchor's own navigation is cancelled. Withmethod: LinkMethod.pusha plain click pushes and a Ctrl-click opens a tab. - The
hrefis the route'slocation, so it carries the mount prefix (AppRoutes.mount(at: '/shop')gives/shop/products/2) and, withlocale: 'fr', the localized spellinglocationFor('fr')writes. Under a hash URL strategy the browser prefixes it with#, as for any link.
- A plain click or a keyboard activation never reaches the anchor:
- Elsewhere there is no anchor;
follownavigates the same way. uri:is for a location you only have as a string (a notification payload, a CMS field). It is a path of this app with the mount prefix, not an external URL. In a debug build auri:that no route matches throws when the link builds: it asks the router above it (GoRouter.configuration.findMatch), orRouteLinkScope.matchbelow. It can't see a segment that doesn't parse (/products/abc), which only the generated matcher does.fspalso warns about aUri.parseliteral that matches no route (since 0.7.0, see Checking string paths).- No
extra. Anextrais not part of the URL, so a link has none. For a route that takes one, callroute.go(context, extra: ...)from the child's ownonTap.
RouteLinkScope sets the defaults for every link below it, once, and needs no generated code:
MaterialApp.router(
routerConfig: router,
builder: (context, child) => RouteLinkScope(
preload: Preload.intent, // for links that don't say
match: AppRoutes.matchUrl, // lets a `uri:` link preload, and checks it exactly
child: child!,
),
)Preloading the data behind a link
A link's preload starts the data of the page it points at before it is followed, so the page shows at once instead of loading.dart. It starts every provider the page reads, the data of each section above it and its own, through route.preload(ref) (or AppRoutes.preload(ref, uri) for a uri: link, given the scope's match), and the link owns the PrefetchHandle it gets back.
Preload.none(the default): nothing; the page loads when it is reached.Preload.intent: when the pointer enters the link, the link or something inside it takes focus, or a pointer goes down on it (a touch, before the finger lifts). Held until the link is disposed, so coming back to the list is still warm.Preload.visible: when the link is on screen: inside the view and the viewport of every scrollable around it, on a route or tab that is showing. Released when it scrolls out or a page covers it, started again when it comes back, and closed when the link is disposed. It is checked after a frame in which the link was built, its scrollable moved or its route went under another, so a long list costs one comparison per scroll frame for the links it has built, and no timer.
What it never does:
- navigate, or run
guard.dartorredirect.dart(a guard runs when the link is followed; preloading is only the load); - keep a failure (a provider that throws closes the handle, so no page is left with an error nobody asked for);
- load twice for repeated hovering (a link holds one handle, and a provider that several links start is loaded once).
A link that failed to preload tries again on its next intent, but not on every scroll tick of a visible one. Changing the link's route or preload releases what it held.
A link to a deferred route (since 0.7.0) starts the page's code too, through the same route.preload(ref); the code, once loaded, stays loaded, so a link releasing its handle drops the data only. The same call is there without a widget, for your own queue: ProductRoute(id: 2).preload(ref) and AppRoutes.preload(ref, uri) return one PrefetchHandle; close it when the lease ends.
onPreload (since 0.9.0) is for what the page needs that is not a provider, such as the image it shows, at the size it shows it. The page's size is known only where there is a BuildContext, which is why this is a callback of the link and not part of route.preload(ref).
- It runs with the link's
BuildContextright after the link startsroute.preload(ref): forPreload.intenton the first intent (and on the next one after a failed preload), forPreload.visibleeach time the link comes back on screen. - It is not called when the link preloads nothing (
Preload.none, or auri:link noRouteLinkScope.matchmatches). - It must return at once. What it throws is reported with
FlutterError.reportError(libraryfespalier,while running onPreload of a RouteLink to /products/3) while the preload goes on.
RouteLink(
to: ProductRoute(id: p.id),
preload: Preload.intent,
// The page shows the photo pagePhotoSize wide: warm that size, not the row's.
onPreload: (context) => ResponsiveImage.precache(context, p.image, width: pagePhotoSize, aspectRatio: 1),
builder: (context, follow) => ListTile(title: Text(p.name), onTap: follow),
)See Precaching an image behind a link.
RouteLink needs the app's ProviderScope above it, like every fespalier page, even when it preloads nothing. It depends on package:url_launcher (only its Link; nothing is launched, though pub resolves url_launcher's platform packages).
Opening the app: launches and platform links
Since 0.11.0. Two things start a navigation that the app's own code did not: a package that knows where the app was opened from (a notification tap that cold-started it, a home-screen shortcut), and the platform handing over a link (an App Link, a Universal Link, a custom scheme).
A launch. An adapter's launch() answers an InboundLaunch:
InboundLaunch('/orders/42', source: NavigationSource.notification);
InboundLaunch.to(OrderRoute(id: 42), source: NavigationSource.shortcut, extra: payload);launchRouter(launch, (launch) => GoRouter(...), links: true) builds the router. make receives the launch that applies (null on the web, where the address bar is the launch and a launch is never used). A launch wins over the platform's initial route (go_router lets a cold-start deep link beat initialLocation, unless overridePlatformDefaultLocation is set, which a launch sets) and the first navigation is marked with launch.source. fespalier_launch (since 0.18.0) answers launch() for a home-screen shortcut or a widget tap, see Shortcuts and widgets.
A platform link. With links: true (an app with telemetry, or with an adapter that has an onEnter, since 0.16.0), launchRouter registers one binding observer before go_router's provider does, launch or not. Each link the running app receives is marked NavigationSource.link, and so is a deep link in the platform's initial route; an in-app go to the same location is not. Links are matched the way go_router reads them (a full URL, a trailing slash or none). The observer only stores the location: no timer, no frame, no listener on the router. An app with neither telemetry nor an adapter that has an onEnter adds nothing (adapters without an onEnter add nothing since 0.16.0; before, any adapter added the observer), and a router made with links: false is never marked. Never on the web.
- Android hands a cold-start link to Flutter as the initial route, so it is the router's first navigation (
InboundNavigation.initial), markedlink. - iOS delivers the link after the first frame, as a link received while running: the router first shows
initialLocation(or the launch), and the link follows as a warm navigation, notinitial. An adapter cannot rewrite it inlaunch();onEntersees it. - A host that drives
NavigationChannel.pushRoute(add-to-app) is markedlinktoo.
An adapter's onEnter (a getter that answers a function, since 0.16.0) sees the same mark as InboundNavigation.source. Any onEnter makes go_router parse every navigation asynchronously and applies its redirect limit to each. InboundNavigation.initial is true when go_router has no route yet, and that navigation must not be blocked (fespalier allows it and reports it).
Generated. AppRoutes.router(launch:) calls launchRouter for you: launch is the InboundLaunch the app was opened with (null: none), the router starts at launch.location with launch.extra over the platform's initial route, and links: true is passed when the app has telemetry: true (links: AppAdapters.handlesEnter with adapters: and no telemetry, since 0.16.0). The generated main asks AppAdapters.launch() once, after beforeRun() and before the router exists, and passes the answer to AppRoutes.router(launch: AppMain.launch); with main: manual your main() does the same (final launch = await AppAdapters.launch();, see With main: manual). An app.dart router() that builds the router itself passes launch: AppMain.launch on, or fsp warns (see Adapters). launch: is ignored on the web.
With adapters, AppRoutes.router passes go_router onEnter: AppAdapters.handlesEnter ? onEnter : null (since 0.16.0): AppRoutes.onEnter forwards to the onEnter of the adapters that have one (FespalierAdapter.onEnter, a getter that answers a function, is not null), in the pubspec's order, and it is passed only when at least one adapter has one. No first-party adapter has one, so an app with fespalier_push, fespalier_analytics or fespalier_download keeps go_router's synchronous parsing, as an app without adapters does (there is no AppRoutes.onEnter without adapters). An app that builds a GoRouter of its own passes onEnter: AppAdapters.handlesEnter ? AppRoutes.onEnter : null itself.
Any onEnter, this one included, makes go_router parse every navigation asynchronously and apply its redirect limit to all of them, so for an app with an adapter that has one, router.go('/a'); router.push('/b'); in one handler ends with /b pushed on the location from before the go (the go is lost: the router has not committed it when the push reads the location) and currentConfiguration lags until the next frame. Navigate once per handler there, or push after the go committed. Before 0.16.0 every app with adapters: had this, whether or not an adapter had anything to say. An adapter written for 0.15 or earlier that defines an onEnter method no longer compiles, and changes it to the getter (Adapters).
Platform links end to end
Since 0.12.0. No fespalier package carries platform links: Flutter does (deep linking is on by default since Flutter 3.27, and fespalier supports 3.35 and later), and the generated router already marks each link NavigationSource.link. What you do is four steps, each with one check:
Say where the app lives.
links:in the pubspec lists thedomains(and ascheme, if you have one); see Deep links and a sitemap.Let
fsp linkswrite the files.fsp linkswritesassetlinks.json,apple-app-site-associationand the sitemap, and withandroid_manifest:andios_entitlements:it edits the intent filters and theapplinks:entries too.fsp links --checkis the CI check that they are current.Serve
.well-known.https://<domain>/.well-known/assetlinks.jsonand.../apple-app-site-associationhave to answer with no redirect. Android verifies a domain when the app is installed, iOS when the app is installed and again from time to time, so a wrong file shows up on a device, not in a build.Open a link on a device. The platforms' own tools open a link the way a user's tap does:
textadb shell am start -a android.intent.action.VIEW -d "https://shop.example.com/orders/42" com.example.shop xcrun simctl openurl booted "https://shop.example.com/orders/42"Pass the application id so Android does not show a chooser. A link that opens a browser instead means the domain was not verified (
adb shell pm get-app-links com.example.shoplists each domain's state).
In a widget test, sendPlatformLink (in package:fespalier/testing.dart) sends the message the engine sends when a link arrives while the app runs, which is what the device does after step 4:
testWidgets('a link opens the order', (tester) async {
await pumpRouter(tester, AppRoutes.router());
await sendPlatformLink(tester, Uri.parse('https://shop.example.com/orders/42'));
expect(currentLocation(tester), contains('/orders/42'));
});It pumps until nothing is scheduled. It is a warm link: for a cold start, set tester.binding.platformDispatcher.defaultRouteNameTestValue before pumpRouter. The router has to come from a launchRouter with links: true (an app with telemetry, or with an adapter that has an onEnter) for onEnter and telemetry to see source=link; the page opens either way. A widget test cannot say whether a platform verified the domain: only step 4 does.
fsp links warns when Flutter's switch is off. With <meta-data android:name="flutter_deeplinking_enabled" android:value="false" /> (any value but true) inside the <activity> of AndroidManifest.xml, or FlutterDeepLinkingEnabled set to <false/> (or NO, 0) in ios/Runner/Info.plist, Flutter does not hand links to the router, and no intent filter or association file makes a link open a page. fsp links and fsp links --check say so (flutter_deeplinking_enabled is false in {manifest}: Flutter will not hand links to the router, and the same for FlutterDeepLinkingEnabled). It is a warning: both files are only read, and --check does not fail on it, because the switch is off on purpose with a deep-link plugin (below).
With a deep-link plugin (app_links, Branch)
Since 0.12.0. A plugin that reads links itself (app_links, or an attribution SDK such as Branch) asks you to turn Flutter's switch off, so that the engine and the plugin do not both open the link. With the switch off fespalier sees no platform link: go_router gets none, onEnter is never called for one and nothing is marked link. The plugin hands them over instead, and the seams to forward them already exist: an adapter of your own (a fespalier_adapter.dart in a small package of the repository that the app lists as a path dependency and under adapters:; the app itself cannot be one; see Adapters that need your code):
- The cold start is a
launch(). Ask the plugin for the link that opened the app (getInitialLink()inapp_links) and answerInboundLaunch(location, source: NavigationSource.link). The router starts there and the first navigation is markedlink. - A warm link is
navigateFrom. Inattach(router, container), forward each link the plugin delivers withnavigateFrom(NavigationSource.link, () => router.go(location)). Let Riverpod own the subscription: aStreamProviderover the plugin's stream, and acontainer.listeninattach, which dies with theProviderScope. app_links' stream also delivers the initial link. Taking it inlaunch()and again from the stream would open the cold link twice. The recipe remembers the URIlaunch()used and skips the first stream event equal to it (or take the cold start from the stream alone, and letlaunch()answer null).- A deferred deep link (a link an attribution SDK gives the app on its first launch) is a
launch()only when it is already cached on the device:launch()is asked before the first frame, so aFuturethere delays it, and it must stay a local read, never the network. One the SDK resolves over the network arrives later, andattachforwards it withnavigateFrom(NavigationSource.link, ...)like a warm link.
// packages/my_links/lib/fespalier_adapter.dart (`adapters: [my_links]` in the app's pubspec)
const adapter = _Links();
/// The plugin's links, owned by the container: Riverpod subscribes and cancels.
final _links = StreamProvider<Uri>((ref) => AppLinks().uriLinkStream);
/// What `launch()` opened, so that the stream's copy of it is skipped once.
Uri? _opened;
final class _Links extends FespalierAdapter {
const _Links();
@override
FutureOr<InboundLaunch?> launch() async {
final uri = await AppLinks().getInitialLink(); // a local read
final location = uri == null ? null : locationOf(uri);
if (uri == null || location == null) return null;
_opened = uri;
return InboundLaunch(location, source: NavigationSource.link);
}
@override
void attach(GoRouter router, ProviderContainer container) {
container.listen(_links, (_, next) {
final uri = next.value;
if (uri == null) return;
if (uri == _opened) {
_opened = null; // the cold link launch() already opened
return;
}
final location = locationOf(uri);
if (location != null) {
navigateFrom(NavigationSource.link, () => router.go(location));
}
});
}
}attach runs once per router: an app that builds a second router over the same container adds a second listener, and both would navigate their own router. locationOf is yours: it maps the plugin's URI to a location of the app, with the mount prefix, and answers null for a link that is not the app's. The fsp links files are still the ones to deploy (the plugin reads the same intent filters and association files), and the warning above is then expected: leave it, or keep the switch on and let fespalier handle links without the plugin. In a test, the plugin's own fake replaces sendPlatformLink: that helper goes through Flutter's channel, which delivers a link to the router only while the switch is on.
Deferred routes: a page's code on demand
Since 0.7.0. A Flutter web app is one JavaScript bundle: every page's code is downloaded before the first frame. Dart can split it: a library imported deferred as is compiled to a file of its own that the browser fetches when loadLibrary() is called. fespalier does that for a route's page.dart, and loads the code the way it loads data: when the page is built, or ahead of time (see Preloading).
Turn it on. It is off by default. For a folder, in its route.dart, which covers that folder and everything below it:
// lib/app/checkout/route.dart
const deferred = true;For the whole app, in the pubspec's fespalier: section (a route.dart says const deferred = false; to opt a folder out, the landing page for one):
fespalier:
deferred: trueThe value is read from the source, so it must be a true or false literal and declared once; anything else is an error with a code frame, and a pubspec value that isn't a bool is serde's own error: invalid pubspec.yaml: fespalier.deferred: invalid type: string "maybe", expected a boolean at line 3 column 13. Like every route.dart constant it is inherited by (group) folders and folders without a page, and needs no page beside it. fsp routes tags such a route deferred, --json has "deferred":true (only there for a deferred route), --graph marks it, and AppManifest's RouteInfo.deferred says so at runtime.
Only page.dart is deferred, the page of a route (a tab layout's own page included). The rest is needed before a page exists, and stays in the main bundle:
| Stays eager | Why |
|---|---|
layout.dart | It wraps a Navigator (a StatefulNavigationShell for tabs): a placeholder would unmount them and their state |
loading.dart, error.dart | They are what shows while the code loads, or when loading it fails |
guard.dart, redirect.dart | They run synchronously, before any build, and decide before a byte of code is fetched |
data.dart, action.dart | The typed routes, matchUrl, dataAt and preload name their providers synchronously |
meta.dart, transition.dart, present.dart | Used in a const list, or build the Page itself |
A redirect.dart route has no page, so it is never deferred.
What shows while it loads. Navigation completes at once, as ever. The route's Page is built, its transition plays, the layout and the tab bar around it are there, and the page's place shows the nearest loading.dart (a centred spinner without one) until the code arrives. If it can't be fetched (offline, a stale deploy), the nearest error.dart shows with what loadLibrary() threw (a DeferredLoadException on the web), and its retry loads the code again. dart2js already tries a chunk three times before it gives up. Turning deferred on therefore binds the nearest loading.dart and error.dart to the route, as data.dart does, with the same rule: an inherited view must fit every route it covers (an error.dart that asks for a segment fails the route that has none, and one that asks for a query parameter adds it to the typed route).
Once the code is loaded, the page is built synchronously, with no Future and no extra frame, so a second visit or a visit after a preload costs nothing. The page's state survives the load.
Data and code load in parallel. A deferred page with a data.dart starts its code at the first build, beside the data (DataView(library: ...)), and shows the page when both are there; loading.dart covers both waits, keep_previous is unchanged, and a data error still gets the data's retry.
Guards run first. A guard that redirects means the page's code is never requested. Preloading never runs a guard and never navigates; it may download the code of a guarded page (code, not data), and the guard still decides when the page is reached. An unparsable segment shows not_found.dart, and loads no code.
Preloading the code. It goes with the data:
XRoute(...).preload(ref)(and so aRouteLinkwithpreload, andAppRoutes.preload(ref, uri)) also starts the page's code. A route that reads no data returns a closed handle; the code, once loaded, stays loaded, so closing a handle only drops the data. A typed route preloads its own page's code, not the pagesgostacks under it.AppRoutes.deferredlists theDeferredLibraryof each deferred route (a getter since 0.20.0, which a hot reload's swap lists again), andAppRoutes.loadDeferred()loads them all, which is the "once the app is idle" strategy: call it after the first frame. There is noconst preload = true;, and no limit on how many load at once (each loads once, and the browser dedups). A deferred route a hot reload adds loads on its first visit there too (since 0.20.0).RouteLinkwith auri:and aRouteLinkScope.matchpreloads through the matched route'spreload.
Platforms. On the web each deferred page is a main.dart.js_N.part.js. Elsewhere the code is in the binary already, but loadLibrary() still takes a turn of the event loop, so a page shows its loading.dart for about a frame on its first visit unless it was preloaded. Load them before runApp there: it costs no I/O, and keeps a deferred page's restorable state working on Android and iOS.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb) await AppRoutes.loadDeferred();
runApp(const ProviderScope(child: MyApp()));
}Android deferred components (a Play Store feature) are not tried: loadDeferred would download all of them. --wasm compiles, and whether it splits is not verified; correctness doesn't depend on it. examples/shop defers /checkout and /products/:id; fsp size reports what each chunk costs per route.
A type declared in a deferred page.dart is an error. The generated file names the types of segments, query parameters and extra outside the page, and Dart can't use a deferred library's type there (type_annotation_deferred_class). An enum (enum Sort { name, price }) or an extra class declared in the page's own file is therefore reported, at the page, with what to do:
error: `Sort` is declared in this page.dart, which is deferred, and the generated code names it outside the page (as the type of a segment, a query parameter or an `extra`): Dart can't use a deferred library's types there. Move `Sort` to a file of its own and import it here, or say `const deferred = false;` in this folder's route.dartMove the type to a file of its own (lib/models/sort.dart) and import it in the page (enum segments can be declared in any file the page imports), or leave that folder eager. A type declared in a page that is not deferred is fine for a deferred child.
Testing. A deferred library's loadLibrary() completes only on the real event loop, which a widget test's pump never runs, so a test would show loading.dart for ever or end with "A Timer is still pending". pumpRouter therefore loads every deferred route's code first, in runAsync, and a deferred page is in the first settled frame like an eager one. A test that pumps a router of its own does the same itself, before pumpWidget:
await tester.runAsync(AppRoutes.loadDeferred);Forgetting it is a FlutterError in a debug build, not a hang: "The code of products/$id/page.dart is not loaded, and a widget test can't load it while it pumps." The loading state of a real deferred page can't be seen in a widget test; test your own loading.dart, or DeferredLibrary(() => completer.future, 'x/page.dart', loadsInFakeAsync: true) with a DeferredView.
Not built: deferring a layout.dart, a const preload = true; strategy, a cap on parallel loads, and a deferred: auto mode (dart2js already makes a chunk of each deferred import, and moves shared code into shared chunks: deferred: true in the pubspec and const deferred = false; on the landing page is "everything split"). Don't defer the landing page: it would show loading.dart before its first paint.