Adapters in the generated main()
Since 0.9.0 a package can plug into the generated main() with one line in pubspec.yaml, instead of code you write in startup.dart:
dependencies:
my_tools: ^1.0.0 # a package that ships lib/fespalier_adapter.dart
my_reporter: ^1.0.0
fespalier:
adapters: [my_tools, my_reporter] # Dart package names, in orderThe convention. fsp has no table of packages and reads no manifest. For each name n it imports package:n/fespalier_adapter.dart and calls its top-level adapter, a FespalierAdapter from package:fespalier/startup.dart. The output depends on the pubspec alone, never on pub get or the pub cache, so fsp check gives the same bytes before and after it. A package of your own can be an adapter:
// package:my_tools/fespalier_adapter.dart
import 'package:fespalier/startup.dart';
const adapter = MyToolsAdapter();
class MyToolsAdapter extends FespalierAdapter {
const MyToolsAdapter();
@override
List<ProviderObserver> providerObservers() => [MyObserver()];
}The adapters on the roadmap (error reporting, analytics, notification and shortcut launches, ...) are packages of this kind; fespalier_push (since 0.13.0) is the first, see Push notifications, fespalier_analytics is a sink installed in beforeRun, see Analytics, and fespalier_download (since 0.15.0) opens a typed route from a download's notification tap, see Notification taps, and fespalier_launch (since 0.18.0) opens a typed route from a home-screen shortcut or widget tap, see Shortcuts and widgets. FespalierAdapter has nine members, each with a default that adds nothing, so an adapter overrides what it needs:
| Member | When it runs | What it is for |
|---|---|---|
zone(body) | Around all of main(), outside startup.dart's zone(); before the binding exists | An SDK that wraps the app (SentryFlutter.init(..., appRunner: body)). Call body once |
beforeRun() | In main() after WidgetsFlutterBinding.ensureInitialized(), before runApp | Installing a telemetry sink (FespalierTelemetry.add), opening a store. Return null for nothing to wait for |
overrides() | Once, after startup() succeeded, before startup()'s own overrides | dataCacheStorage, reconnectSignal, a flag source |
providerObservers() | With startup()'s providerObservers, the adapters' first | A ProviderObserver |
routerObservers() | When the router is built, before startup.dart's routerObservers | A NavigatorObserver (a new one on each call: an observer belongs to one navigator) |
wrap(root) | Around the root widget, outside the ProviderScope and the splash too | SentryWidget, PostHogWidget |
attach(router, container) | Once per router (since 0.11.0), after the first frame that shows the router (the app's ProviderScope exists) | Subscribing to something that outlives a screen (a notification tap, a shortcut) with container.listen. Do not navigate synchronously |
launch() | Once, after beforeRun(), before the router is built (since 0.11.0); never on the web | Where the app was opened from: a notification tap that cold-started it, a shortcut. Return null when this adapter did not; the first answer wins |
onEnter (a getter) | For every navigation go_router parses, the first one included (since 0.11.0; a getter since 0.16.0) | Rewriting a custom-scheme link. Return a function: null for no say, Allow(then:) or Block.then(...); never block navigation.initial |
Write adapters with extends FespalierAdapter, never implements: a member added later (attach is one, since 0.11.0) has a default for a subclass and breaks a class that implements all of them.
otel: true is not an adapter (since 0.19.0). It is a key in the pubspec, and fsp writes the OpenTelemetry wiring into the generated main() (OpenTelemetry from the generated main()). Its zone is the innermost of the generated zones, inside the adapters' zone, and its start and its sink come before their beforeRun, so an adapter's beforeRun finds the SDK up.
onEnter is a getter that answers a function (since 0.16.0). An adapter that has something to say about a navigation overrides the onEnter getter and returns a FespalierEnter, a function of the InboundNavigation; every other adapter keeps the default, null:
class LinkRewriter extends FespalierAdapter {
const LinkRewriter();
@override
FespalierEnter? get onEnter => (navigation) {
if (navigation.next.uri.scheme != 'vaam') return null;
return Block.then(() => navigation.router.go('/orders/42'));
};
}It is a getter, and not the method it was in 0.11.0 to 0.15.0, so that the choice cannot be missed. Any onEnter makes go_router parse every navigation asynchronously, so AppRoutes.router passes one only when some adapter's getter is not null (AppAdapters.handlesEnter), and fsp cannot tell which adapters have one (the output is a function of the pubspec alone). With a method, an adapter that forgot the opt-in would be skipped without a word. With a getter, an adapter written for 0.15 that still defines onEnter(InboundNavigation navigation) { ... } does not compile: Class 'LinkRewriter' can't define method 'onEnter' and have field 'FespalierAdapter.onEnter' with the same name. Change the method to the getter above (the migration notes have a before and after). The getter is read once, when the router is built, and only the adapters whose getter is not null are asked, in the pubspec's order.
An app whose adapters have no onEnter keeps go_router's synchronous parsing: router.go('/a'); router.push('/b'); in one handler ends with /a > /b. With an adapter that has one it ends with /b pushed on the location from before the go (the go is lost) and currentConfiguration lags until the next frame, so write an onEnter only when launch(), attach() and a listen cannot do the job. No first-party adapter has one.
Order and rules. The first package in the list is the outermost: its zone and its wrapper go around the others'.
- Each name must be under
dependencies:in the pubspec. A name that is not a package name, is listed twice, isfespalieritself, or is not a dependency is an error.main: manualwithadapters:is fine since 0.11.0 (it used to be an error): your ownmain()calls them throughAppAdapters, see Withmain: manual. adapters:makesmain: autowritelib/app.main.g.darteven with noapp.dart,startup.dartorsplash.dart.adapters: auto(since 0.23.0) takes the first-party adapter packages underdependencies:(todayfespalier_analytics,fespalier_download,fespalier_launchandfespalier_push), in the orderdependencies:lists them. It is opt-in: each of them needs itsconfigurecall inmain()(Adapters that need your code), and an app that depends on one for something else, such asfespalier_downloadbehindfespalier_maps' file packs, would report an error at start if it were on by default.fsp doctorshows each adapter as on or off, and whether a Dart file underlib/calls itsconfigure.- There are no per-adapter options in the pubspec: what an adapter needs at deploy time comes from
--dart-define, and anything custom stays instartup.dart, which is yours.
What gets generated. With adapters: the generated lib/app.g.dart (not the main) imports each adapter and defines AppAdapters (since 0.11.0), whatever main: says; the generated main only calls it. With adapters: [my_tools, my_reporter]:
// lib/app.g.dart
import 'package:fespalier/startup.dart' show FespalierAdapters, Override;
import 'package:my_tools/fespalier_adapter.dart' as _a0;
import 'package:my_reporter/fespalier_adapter.dart' as _a1;
abstract final class AppAdapters {
static final _all = FespalierAdapters([_a0.adapter, _a1.adapter]);
static Future<void> zone(Future<void> Function() body) => _all.zone(body);
static Future<void>? beforeRun() => _all.beforeRun();
static FutureOr<InboundLaunch?> launch() => _all.launch();
static List<Override> overrides() => _all.overrides();
static List<ProviderObserver> providerObservers() => _all.providerObservers();
static List<NavigatorObserver> routerObservers() => _all.routerObservers();
static Widget wrap(Widget root) => _all.wrap(root);
static bool get handlesEnter => _all.handlesEnter; // any adapter's onEnter is not null, since 0.16.0
}
// AppRoutes.attach(router, [container]) also runs each adapter's attach(router, container)
// AppRoutes.onEnter(context, current, next, router) forwards to _all.onEnter (since 0.11.0), and
// AppRoutes.router(launch:, ...) passes RoutingConfig(onEnter: AppAdapters.handlesEnter ? onEnter : null)AppRoutes.router takes launch: (an InboundLaunch), with or without adapters, and AppRoutes.onEnter exists only with adapters. go_router gets it only when an adapter has an onEnter (AppAdapters.handlesEnter, since 0.16.0; before, any adapters: entry passed it): any onEnter makes go_router parse every navigation asynchronously and count against its redirect limit, so without one go_router keeps its simplest code path, as in an app without adapters. An app that builds a GoRouter of its own passes onEnter: AppAdapters.handlesEnter ? AppRoutes.onEnter : null. See Opening the app.
FespalierAdapters (in package:fespalier/startup.dart) holds the order, so it is Dart that is tested, not generated text. These are the lines of lib/app.main.g.dart that are new, for a startup.dart with a zone() and a routerObservers (the rest is what the root files alone make):
static Future<void> run() => AppAdapters.zone(() => _i1.zone(_main));
if (AppAdapters.beforeRun() case final ready?) await ready;
final launch = AppAdapters.launch();
_launch = launch is Future<InboundLaunch?> ? await launch : launch;
static InboundLaunch? get launch => _launch;
static Widget root({GoRouter Function() router = _router}) => AppAdapters.wrap(StartupGate(
extraOverrides: _extraOverrides,
// ... overrides:, observers:, retry:, as before
attach: AppRoutes.attach,
router: router,
app: app,
));
static List<NavigatorObserver> routerObservers() => [...AppAdapters.routerObservers(), ..._i1.routerObservers];
GoRouter _router() => AppRoutes.router(launch: AppMain.launch, observers: AppMain.routerObservers());
List<Override> _extraOverrides() => [...AppAdapters.overrides()];The main no longer imports the adapters (0.10.0 and earlier did, as _a0.adapter), so an app with adapters regenerates both files.
Without adapters: none of these lines is written, and app.g.dart is what it was before adapters existed.
What stays true. An adapter follows the rules of fespalier itself:
- No timer.
- Sync stays sync. A
beforeRun()that returnsnullis not awaited, so noFutureand no microtask. AFutureis awaited and delays the first frame, with the platform's native splash still showing, so keep it to a local read, never the network. - Nothing touches a platform plugin until it is used, so
AppMain.root()boots in a widget test. overrides()goes beforestartup()'s: overriding a providerstartup()also overrides is Riverpod's "Tried to override a provider twice" in debug.
attach (since 0.11.0). The generated main passes AppRoutes.attach to the StartupGate, which calls it after the first frame that shows the router (an adapter may change a provider there), with the router and the ProviderContainer of the app's ProviderScope. AppRoutes.attach(router) without a container (what AppRoutes.router() does) only follows the router for DevTools, observe.dart and telemetry. Each adapter is attached once per router, in the pubspec's order, and one that throws is reported with FlutterError.reportError while the others still run. pumpRouter does not attach adapters, so a widget test of a page never runs them. The app's own attach(router, container) in startup.dart (since 0.12.0) runs right after the adapters' attach; its ready(container) runs earlier, before the router exists: ready() and attach().
launch and onEnter (since 0.11.0). FespalierAdapters.launch() asks every adapter once, in order, and the first non-null answer (an InboundLaunch) wins; it is null on the web and sync unless an adapter answers with a Future. FespalierAdapters.onEnter (what the generated AppRoutes.onEnter forwards to) composes the answers of the adapters whose onEnter getter is not null (since 0.16.0; FespalierAdapters.handlesEnter is true when any is, the others are not asked): the first Block wins, the Allow.then callbacks run in order as one, and no say is Allow(). Blocking the initial navigation is refused: it is allowed, reported with FlutterError.reportError in every mode, and the block's then still runs; answer launch() instead. InboundNavigation.initial is true when go_router has no route yet (it hands the same state as current and next). A Block.then for a platform link keeps the link's mark: the go inside it is reported with source=link. See Opening the app.
Showing state in DevTools (since 0.16.0). An adapter that has state worth looking at while the app runs (the flags it resolved, the last notification it opened) can list it in the DevTools extension's Adapters tab, and let a developer set a value from there:
// in the adapter package, whenever its state changes
if (kFespalierDevTools) {
DevToolsAdapters.show(
'my_tools', // your package's name: one card per name
{'mode': mode, 'last_payload': lastPayload},
title: 'My tools',
editable: {'mode'}, // keys a developer may set
onEdit: (key, text) => setMode(text), // in the app; usually ends with another `show`
);
}show replaces what the package showed before, keeps the first 64 keys (and 32 packages) and turns each value to its type and the start of its toString() at once, so the map is not kept. Call DevToolsAdapters.hide('my_tools') from the ref.onDispose of the provider that showed it. It is behind kFespalierDevTools: in a release build both are empty calls, and building the values under the same if keeps the map out of the release build too. It starts no timer and listens to nothing, so call it when your state changes, not on a schedule. The registry holds onEdit until hide or the next show: do not close over a Ref that should be gone. An onEdit that throws is answered to DevTools as onEdit threw: ...; it never reaches the app.
Two things to check.
lib/main.darthas to callAppMain.run(), or callAppAdaptersitself. Adapters are wired by the generatedmain()(or byAppAdapters, below): an app whose ownmain()still runsrunAppby hand ignores them, without a message (fspdoes not readlib/main.dart). The line isFuture<void> main() => AppMain.run();.- An
app.dartthat builds the router (GoRouter router()) has to pass the adapters' observers on:AppRoutes.router(observers: AppMain.routerObservers()). Without thatfspwarns, on app.dart at the function:app.dart's router() builds the router itself, so the adapters' router observers are not added: pass `observers: AppMain.routerObservers()` to `AppRoutes.router(...)` there. It warns the same way for the launch (since 0.11.0):app.dart's router() builds the router itself, so the adapters' launch is not used: pass `launch: AppMain.launch` to `AppRoutes.router(...)` there.AppRoutes.router(launch: AppMain.launch, observers: AppMain.routerObservers())says both.
In tests. pumpRouter(tester, router, app: AppMain.app) (see Testing) never sees the adapters, and neither do the fsp test smoke tests. AppMain.root() is the app as it runs, adapters included.
With main: manual: AppAdapters
Since 0.11.0 adapters: works with main: manual too: fsp writes no lib/app.main.g.dart, but lib/app.g.dart still defines AppAdapters, and your own main() calls it, in the order the generated one does. An app that brings its own telemetry zone and builds its own router looks like this:
// lib/main.dart, with `fespalier: {main: manual, telemetry: true, adapters: [my_tools]}`
import 'package:fespalier/fespalier.dart';
import 'package:flutter/material.dart';
import 'app.g.dart';
Future<void> main() => AppAdapters.zone(() async {
WidgetsFlutterBinding.ensureInitialized();
// your own setup: a telemetry sink goes here, before the router (FespalierTelemetry.add)
if (AppAdapters.beforeRun() case final ready?) await ready;
final launch = await AppAdapters.launch(); // a notification cold start, a shortcut: null on the web
final container = ProviderContainer(
overrides: [...AppAdapters.overrides() /*, the app's own */],
observers: [...AppAdapters.providerObservers()],
);
final router = AppRoutes.router(
launch: launch,
observers: [...AppAdapters.routerObservers()],
);
AppRoutes.attach(router, container); // each adapter's attach(router, container), once
runApp(AppAdapters.wrap(UncontrolledProviderScope(
container: container,
child: MaterialApp.router(routerConfig: router),
)));
});- Moving from
main: autotomanual: deletelib/app.main.g.dart;fspno longer writes or updates it. - Each adapter's top-level
adapteris read once, at the firstAppAdapterscall. - Call
AppRoutes.attach(router, container)once, after both exist. Without it the adapters'attachnever runs; calling it twice is safe. - An app that mounts the tree in a
GoRouterof its own (AppRoutes.mount) calls the same line with that router. AppAdapters.overrides()goes before your own overrides, as in the generated main.
Adapters that need your code
Since 0.12.0 (a pattern; it needs nothing new in fespalier). The pubspec carries no per-adapter options, and launch() runs before any ProviderScope exists, so a provider override cannot hand an adapter the app's own code: a push adapter needs your mapping from a payload to a route, a launch adapter your sources of shortcut and widget taps and the mapping to a route, a download adapter its backend, store and the mapping from a notification tap, an analytics adapter its backend, a bridge its generated client. The one place that runs before the adapters is your main(). So an adapter that needs app code is configured there, with one static call, before AppMain.run():
// lib/main.dart
Future<void> main() {
FespalierPush.configure(source: MyPushSource(), route: pushRoute); // before the adapters run
return AppMain.run();
}Every package that does this has the same shape, so that a second one holds no surprise:
configureis a static method on anabstract final class(FespalierPush,FespalierAnalytics,FespalierDownload, andFespalierLaunchsince 0.18.0), and the adapter infespalier_adapter.dartreads what it stored. Nothing else is global.- Calling it twice replaces. A hot restart runs
main()again, and the second call wins. - An unconfigured adapter says so once and does nothing. It reports one
FlutterError.reportErrorthat names the missing call (call FespalierPush.configure(...) in main() before AppMain.run()) and leaveslaunch(),attach()and the others as no-ops. It never throws out of them: an adapter must not stop an app from starting. @visibleForTesting static void debugReset()clears whatconfigurestored, for the test that sets up its own.
With main: manual, call configure in your own main() before AppAdapters.zone, the first line of the example under With main: manual. AppMain.root() in a widget test runs no main(), so a test that boots the app calls configure itself (with the package's fake from its testing.dart) before it pumps.
An adapter that needs telemetry: true. A sink that wants page events (screen views for analytics) gets them only from an app generated with telemetry: true: the generated attach is what makes the router report them. An adapter cannot read the pubspec, but its attach(router, container) can ask the router:
@override
void attach(GoRouter router, ProviderContainer container) {
if (!telemetryFollows(router)) {
FlutterError.reportError(FlutterErrorDetails(
exception: StateError('my_analytics needs `telemetry: true` in the fespalier: section of pubspec.yaml'),
library: 'my_analytics',
));
}
}telemetryFollows(router) (since 0.12.0, in package:fespalier/fespalier.dart) is true once telemetryAttach ran for that router, which the generated AppRoutes.attach does only with telemetry: true. It reads, and starts nothing: no watch, no timer, no provider. It says nothing about whether a sink is installed (FespalierTelemetry.current), only whether the router reports to one. The generated AppRoutes.attach calls telemetryAttach just before it calls the adapters' attach, so the answer is settled there.