Data
data.dart: a function, a selector or a provider
data.dart has three forms, told apart by what it exports:
| You write | fespalier | Use it when |
|---|---|---|
Future<T> data(Ref ref, {…}) (or Stream<T>, or T) | wraps it in an autoDispose FutureProvider (StreamProvider for a Stream) | the data is fetched for this route only: the function is the fetch |
ProviderListenable<AsyncValue<T>> data({…}) => productProvider(id) | calls it and uses the provider it returns; nothing is wrapped | a provider for it already exists, above all a riverpod_generator one |
final data = FutureProvider<T>(…) (or StreamProvider, AsyncNotifierProvider, StreamNotifierProvider), type arguments spelled out | uses it as-is | you want to write the provider yourself (a notifier, keepAlive, retry:) and it belongs to this route |
A Stream<T> from a server's event stream, and a provider that holds one and reconnects, are Streaming (fespalier_stream, since 0.25.0): transport.serverEvents(url) in the function form, a LiveFeed in the selector form.
Selecting a provider. If a provider for the data already exists, select it:
// lib/app/products/$productId/data.dart
ProviderListenable<AsyncValue<ProductView>> data({required String productId}) =>
productProvider(productId); // a generated family, a FutureProvider.family, ...Don't write Future<Product> data(Ref ref, …) => ref.watch(productProvider(id).future) instead. That puts a second provider in front of the real one, and awaiting .future in it drops the error the real provider holds while it retries, so the route can't show error.dart during the retry window.
- The return type says so.
ProviderListenable<AsyncValue<T>>with noRefparameter (the function returns the provider, it doesn't read one).Tis what the page's parameter is matched to by type, as withFuture<T>. It is a syntax-only read: a generated provider's own type, likeProductFamily, isn't resolved.ProviderListenablecomes frompackage:fespalier/fespalier.dart. - Parameters are the function form's. Named parameters are segments and query parameters, keyed and typed as in
data(Ref ref, {…}). Any other parameter, a positional one or aRefis an error at that parameter. XRoute.datais the selected provider (ProductDetailRoute.data('x') == productProvider('x')), andwatch,read,prefetchandrefreshall go to it. The generatedDataViewwatches it directly: no wrapper, no.futurehop, one fetch per navigation.refresh(anderror.dart'sretry) invalidates the selected provider and reads it again, so it runs once.- The app's provider keeps its own
retry,keepAliveand dependencies, sodata_retrydoesn't apply to it (it only configures the providers fespalier creates).keep_previousdoes, because it is about what the view shows. The app'sProviderScope(retry: …)applies unless the provider sets its own. - Refresh needs a provider, not just a listenable. The runtime checks what it gets (a
ProviderOrFamilywith a.future, which everyFutureProvider,StreamProviderand generated async provider is). Returning something else, sayproductProvider(id).select(…), builds and watches fine, butrefreshandretrythrow aStateErrorthat says to return the provider itself. - A section's
data.dartcan be a selector too, and takes segments and query parameters like a page's.
In all three forms the route exposes the provider as XRoute.data, keyed by the segments and query parameters data.dart uses:
| Parameters used | Provider | Watch it with |
|---|---|---|
| none | plain | ref.watch(ProductsRoute.data) |
| one | .family<T, int> | ref.watch(ProductRoute.data(42)) |
| several | .family<T, ({String shop, int id})> | ref.watch(ItemRoute.data((shop: 'a', id: 1))) |
A family provider you write yourself follows the same rule for segments: with several, its argument is a record naming the ones it uses, e.g. ({String shop, int id}). It can't be keyed by a query parameter (a record field that isn't a segment is an error). To key by one, write the function form (Future<T> data(Ref ref, {int? page})) or select your provider with a data() that takes it.
To see a scaffolded error.dart and its retry, throw from data.dart, e.g. throw Exception('offline').
Retries and reloads
Two settings in the fespalier: section of pubspec.yaml decide what a route shows while its data.dart fails or loads again:
fespalier:
data_retry: inherit # inherit | none
keep_previous: true # true | falsekeep_previous: true (the default). loading.dart is only for the first load. Once the provider has a value or an error, a reload (ref.invalidate, refresh, the section's dependencies changing) keeps rendering it: the old page stays until the new value arrives, instead of blinking to loading.dart and back, and error.dart's retry keeps the error up until the new run answers. This is skipLoadingOnReload and skipLoadingOnRefresh of Riverpod's AsyncValue.when, for a route's data.dart and a section's, your own providers included. With false, loading.dart shows whenever the provider is loading, except while a write whose optimistic() patched the data settles (since 0.8.1).
data_retry: inherit (the default). Riverpod 3 retries a failed provider on its own, with backoff, and the app's ProviderScope(retry: ...) or ProviderContainer(retry: ...) decides how. The providers fespalier generates for data() functions don't set their own policy, so the app's applies (Riverpod's own default, 10 retries with doubling delays and none for an Error, when the app sets none). To have a failure settle into error.dart after a few attempts:
ProviderScope(
retry: (retryCount, error) => retryCount < 3 ? const Duration(seconds: 1) : null,
child: …,
)A provider you write yourself always follows the app's policy, or its own retry:.
A provider that failed and is being retried is AsyncLoading with its error still held. With keep_previous on, DataView shows that error, not loading.dart, for the whole retry window: error.dart shows as soon as data.dart fails, retrying or not. It goes to the data when a retry succeeds, and stays on the error when the policy gives up. With keep_previous: false a retry shows loading.dart again.
data_retry: none. Every generated data() provider gets retry: (retryCount, error) => null, whatever the app's policy is: a failure is final until error.dart's retry runs it again.
A route with a freshness or a dataCache (since 0.8.1) keeps its data when a reload fails, whatever keep_previous says: error.dart only shows when there is nothing to show (see Freshness). And DataView shows a value that Riverpod's offline persistence restored (isFromCache) while the fresh one loads, with keep_previous: false too.
Telemetry (since 0.18.0): the data span says which try a load is (fespalier.data.attempt). A retry that Riverpod runs on its own after a failure is try 2, 3 and so on, and a reload the app asks for is try 1 again. Telemetry conventions has the names.
Freshness: staleTime, resume and reconnect
Since 0.8.1 a data.dart can say when its value is old enough to load again. It is opt in: a route without the declaration loads once and keeps the value for as long as something watches it.
// lib/app/products/$id/data.dart
import 'package:fespalier/fespalier.dart';
import 'package:shop/api.dart';
/// Fresh for a minute; loaded again when the app comes back to the foreground.
const freshness = Freshness(
staleTime: Duration(minutes: 1),
refetchOnResume: true,
);
Future<Product> data(Ref ref, {required int id}) =>
ref.watch(apiProvider).product(id);freshness is a top-level const or final variable of exactly that name, initialized with a Freshness(...) call (const Freshness(...) and prefix.Freshness(...) are fine). fsp does not read the duration: the generated file refers to _i13.freshness, and the Dart analyzer checks it. It applies to the function form returning a Future<T>, a FutureOr<T> or a plain T; in a data.dart that returns a Stream, selects a provider or exports its own data provider it is an error that says what to do instead. A stream is current by itself: one from a server is Streaming, and its reconnects follow the same signals (reconnectSignal, appShowSignal).
A folder's default. The same constant in a route.dart is the default of every data() function at and below that folder (a section's included). The nearest route.dart wins, and a data.dart's own freshness wins over all of them. A root lib/app/route.dart is the app-wide default.
// lib/app/teams/$teamId/route.dart
import 'package:fespalier/fespalier.dart';
/// Every data() function at and below /teams/:teamId is fresh for 30 seconds.
const freshness = Freshness(staleTime: Duration(seconds: 30));The cascade is a default, not a demand: a selector, a provider form or a Stream below is skipped without a word. A route.dart whose freshness applies to no data.dart is a warning.
What "stale" means. A value is stale once it has been in memory for staleTime since it arrived (the Future completed; for a synchronous data(), the microtask after it was built). A loading value or an error is never stale: Riverpod's retry and error.dart's retry handle those. Nothing polls, and stale data on screen stays as it is until something reads it. A stale value is loaded again when something reads it:
- A new listener. A page opening on it (
DataView), aSectionViewof a page that opens below a section,XRoute.watchin a widget that mounts,prefetch/preload/AppRoutes.preload, aRouteLinkpreloading on hover,XRoute.read. - A listener coming back. A page uncovered by a pop, a tab shown again: Flutter turns
TickerModeback on, and Riverpod resumes the subscription. - A signal, with
refetchOnResumeorrefetchOnReconnect(below).
The stale value shows at once (no Future, no blank frame), and the new one replaces it (stale-while-revalidate). With keep_previous: true the old page stays until the new value arrives; with false loading.dart shows while it loads. If the load fails, the page keeps its data (see keepDataOnError, below), the error is in XRoute.watch(ref).error, and XRoute.refresh(ref) completes with it.
Things to know:
- Every new reader counts. With a small
staleTime, a section's data loads again each time a page below it opens, because that page'sSectionViewis a new listener.Duration.zeroloads on every open, and a prefetch is then only a head start for the first paint. readreturns what is in memory, stale or not, and starts the reload. Userefreshfor a value that is surely fresh: it waits for the network.- An invalidation is not a read.
ref.invalidate,XRoute.refreshand anaction.dart'sinvalidatesload at once, whatever thestaleTime. keepForis another thing.prefetch(ref, keepFor: …)is how long a handle keeps a value in memory with nothing on screen;staleTimeis how long it counts as fresh. With a ten minutekeepForand a one minutestaleTime, opening the page five minutes later shows the kept value at once and loads it again. A handle still alive across an app resume loads underrefetchOnResumetoo;staleTimebounds that.
Resume and reconnect. refetchOnResume: true loads the value again when the app returns to the foreground (AppLifecycleListener.onResume), refetchOnReconnect: true when reconnectSignal fires. Both use the threshold staleTime ?? Duration.zero: without a staleTime every signal loads again, so set one with refetchOnResume unless every focus should reload (an iOS notification shade or a regained browser focus is a resume too). Each signal is a Riverpod provider holding a count, a RefetchSignal, that the data provider listens to:
appResumeSignalfires on resume. It is created only while a provider withrefetchOnResumelistens to it, and itsAppLifecycleListenergoes with it.appShowSignal(since 0.13.0) fires when the app is shown again after being hidden (AppLifecycleListener.onShow: the background or a minimised window), and not when it only went inactive and back (an iOS notification shade or Control Center, a call banner, a system sheet), whichappResumeSignaldoes.fespalier_biometricsrelocks on it. Same rules: created only while something listens, its listener goes with it, and a test without a binding overrides it withappShowSignal.overrideWith(RefetchSignal.new).reconnectSignalnever fires by itself: Flutter has no API for "the network is back". Override it with aRefetchSignalof your own that listens to your connectivity source, or callref.read(reconnectSignal.notifier).fire()where you know.fespalier_connectivity(since 0.9.0, below) is that signal fromconnectivity_plus:reconnectSignal.overrideWith(ConnectivitySignal.new)instartup().
Your own provider. freshData(ref, const Freshness(...), value) is what the generated provider wraps its value in. It returns value itself (a Future stays the Future, a value stays a value), so a selector's target or a provider-form data.dart can give itself the same rules: Future<Product> build() => freshData(ref, const Freshness(…), _fetch());.
A failed reload keeps the page. A route whose data.dart has a freshness or a dataCache (or inherits one) gets keepDataOnError: true on its DataView: a reload that fails (a stale value loaded again, a start offline) leaves the page on its data, and error.dart only shows when there is nothing to show. A refusal is never kept (since 0.13.1): an error that implements DataRefusal (core's marker for "the server decided"; CrateStackRefused of fespalier_cratestack and AuthRejected of fespalier_auth do) shows error.dart instead of the page, because the person may no longer be allowed to see it. Any refusal does, a 404, 429 or 400 included (CrateStackRefused is every 4xx but 401 and 409). AuthRejected counts only when it reaches the read unwrapped: Dio wraps it in a DioException, and a 401 whose refresh failed arrives as CrateStackUnauthenticated, which keeps the page. error.dart gets no copy of the old value. Implement DataRefusal on your own error type to get the same. The cost: a failed refresh() is hidden behind the old page. It completes with the error (show a snackbar), and XRoute.watch(ref).hasError is there for a banner.
Testing. testWidgets runs in fake async and ages data by clock.now(), so await tester.pump(const Duration(minutes: 6)) makes a value stale without waiting and without a timer.
- A resume is
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive)and then.resumed, orcontainer.read(appResumeSignal.notifier).fire()(the container is whatpumpRouterreturns). A reconnect iscontainer.read(reconnectSignal.notifier).fire(). - A test that builds a
refetchOnResumeprovider in a bareProviderContainer, with no binding, throws when the signal is built: usetestWidgets, or overrideappResumeSignal.overrideWith(RefetchSignal.new). - A reload starts on a frame and its value shows on the next, so
pump()a few times (orpumpAndSettlewhen nothing waits on a real delay).
Diagnostics. Errors that say what to write instead: a freshness that is not a Freshness(...) call, a second one, one in a data.dart that returns a Stream, selects a provider or exports its own data provider, and a dataCache in a route.dart (messages). freshness and dataCache are names fsp reads in a data.dart: a public top-level variable of one of those names with another type gets the first error, so rename it (a private _freshness is never read).
Reconnects: fespalier_connectivity
Since 0.9.0. refetchOnReconnect: true waits for reconnectSignal, which never fires by itself. package:fespalier_connectivity is that signal, from connectivity_plus, with two platform repairs and a hasNetwork provider for an offline banner. It takes connectivity_plus >=6.0.1 <8.0.0; add it next to fespalier, with the same url and the same ref (Companion packages says why):
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_connectivity:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_connectivity
ref: v0.25.0One line in startup(), which stays synchronous (no first-frame cost):
// lib/app/startup.dart
List<Override> startup() => [reconnectSignal.overrideWith(ConnectivitySignal.new)];
// lib/app/teams/$teamId/route.dart: every data() at and below is loaded again when the device gets a network back,
// if its value is at least 30 seconds old
const freshness = Freshness(staleTime: Duration(seconds: 30), refetchOnReconnect: true);What fires and what does not. ConnectivitySignal fires when the device goes from no network to a network ([none] to anything else), not on the first answer and not on a Wi-Fi to mobile switch. Every data provider that listens loads again if its value is at least staleTime old (the old value stays on screen, and keepDataOnError keeps the page if the reload fails); within staleTime nothing loads. A reload already under way is not repeated, so a flapping network needs no debounce: it fires each time and loads once. The signal, and its subscription to connectivity_plus, exist only while a data.dart with refetchOnReconnect is alive.
A banner. hasNetwork is a bool provider: false only once the device has said "no network", and true before the first answer, so nothing flashes offline at start. It pairs with XRoute.watch(ref).isFromCache ("offline copy"):
if (!ref.watch(hasNetwork)) const Text('No network')networkConnectivity is the List<ConnectivityResult>? behind it (null until the first answer), for an app that shows the kind of network.
Connectivity versus reachability. This package, like navigator.onLine on the web, answers "is a network interface up?". Only a request tells whether the server answers: a captive portal, a router with no uplink, a VPN that is down or a dead server all look connected. So refetchOnReconnect can fire on a captive portal (the reload fails and keepDataOnError keeps the page); hasNetwork == false is reliable ("no network at all") and true promises nothing, so an offline banner should say "No network" and a failed load should show its own error. fespalier ships no reachability check (it needs a request to your own server, and polling is a timer); a recipe in skills/fespalier-data/references/reconnect-and-network.md asks your own API once per connectivity change and per resume, never on a timer, and turns that into a RefetchSignal.
Two platform repairs. The web sends no first state (only online and offline events), so the first state is asked with check(); an event that arrives before that answer wins. iOS drops connectivity events while the app is in the background, so networkConnectivity asks check() again on each resume, through appResumeSignal. Neither starts a timer.
Testing. package:fespalier_connectivity/testing.dart has FakeConnectivity, a ConnectivitySource whose set, offline() and online([via]) deliver a change synchronously, whose check() answers now, and which sends nothing on listen (like the web). Override connectivitySource with it (and reconnectSignal with ConnectivitySignal.new, as startup() does; pumpRouter does not run startup()):
final fake = FakeConnectivity();
await pumpRouter(
tester,
AppRoutes.router(initialLocation: '/teams/acme/members/7'),
overrides: [
connectivitySource.overrideWithValue(fake),
reconnectSignal.overrideWith(ConnectivitySignal.new),
],
);
await tester.pump(const Duration(seconds: 31)); // the team is stale now (the fake clock)
fake.offline();
fake.online(); // a reconnect: the team loads again, once
await tester.pumpAndSettle();A widget test that reaches the plugin without that override fails, with Flutter's report while activating platform stream on channel dev.fluttercommunity.plus/connectivity_status and MissingPluginException(No implementation found for method listen on channel dev.fluttercommunity.plus/connectivity_status) (a test that shows hasNetwork, or builds a refetchOnReconnect provider with the package's signal, and no FakeConnectivity).
In debug (debugPrint, nothing in a release build) it prints fespalier_connectivity: the connectivity stream reported an error: <error> and fespalier_connectivity: checking connectivity failed: <error>, and both leave the state as it was (causes).
A cache that survives a restart: dataCache
Since 0.8.1 a data.dart can save its last value, and show it at the next start while the fresh one loads. It is built on Riverpod 3's experimental offline persistence (persist() and its Storage interface), isolated in one runtime file, and it is opt in twice: the data.dart declares how its value is saved, and the app gives a place to save it.
// lib/app/products/$id/data.dart
import 'package:fespalier/fespalier.dart';
import 'package:shop/api.dart';
final dataCache = DataCache<Product>.json(
toJson: (p) => p.toJson(),
fromJson: (j) => Product.fromJson(j! as Map<String, Object?>),
);
Future<Product> data(Ref ref, {required int id}) =>
ref.watch(apiProvider).product(id);dataCache is a top-level const or final variable of exactly that name, initialized with DataCache(...), DataCache<T>(...), DataCache.json(...) or DataCache<T>.json(...). .json saves jsonEncode(toJson(value)) and reads back fromJson(jsonDecode(saved)); the plain constructor takes encode and decode between your value and a String. maxAge (default two days) is how long a saved value may be shown at a start; version is a string you change when what encode writes changes shape (values saved under another version are dropped, not decoded). Like freshness it applies to the function form that loads once, and a route.dart can't have one (each data type has its own encode and decode).
Where it is saved. dataCacheStorage is a provider that is null by default, so nothing is saved until the app gives one in its ProviderScope:
ProviderScope(
overrides: [dataCacheStorage.overrideWithValue(MemoryDataStorage())],
child: const ShopApp(),
)MemoryDataStoragekeeps values while the app runs: a page that is disposed and opened again shows its last value at once. For the web, examples and tests; it does not survive a restart.- A
Storage<String, String>on disk survives one.fespalier_storage(since 0.9.0, below) is a tested one on shared_preferences or Hive, with a size budget;riverpod_sqflite'sJsonSqFliteStorageplugs in as it is. To write your own, importpackage:fespalier/persist.dart(it re-exportsStorage,PersistedData,StorageOptionsandStorageCacheTime);readreturns aPersistedData<String>?.
For an app that keeps working without a network (reads that say how current they are, writes that wait, rows synced later), see Offline-first and CrateStack with fespalier.
A storage whose read is synchronous gives the saved value on the first frame. A Future<Storage> is fine too: there is one loading.dart frame, then the saved value.
What happens.
- The first time a route's provider is built (and each time after it was disposed), a saved value that has not expired and has the same
versionis the state:AsyncLoading(value: saved)withisFromCache == true, and the fetch starts at the same time.DataViewshows such a value whateverkeep_previoussays. ReadisFromCachefromXRoute.watch(ref)for an "offline copy" banner. - The fetch succeeds: the state is the fresh
AsyncData, and it is saved. - The fetch fails: the page keeps the saved value (
keepDataOnError), and the saved value is kept. Riverpod'spersistdeletes it on any error, so an offline start would lose the cache for the next one; fespalier guards that. An offline cold start shows the last data, and so does the next one, untilmaxAge. - A saved value that does not decode is dropped, not reported as an error, and the load goes on. A storage that throws never becomes the route's error either.
- A cold start always loads again, whatever the
staleTimeis. Nothing the storage answers after the load has finished replaces the fresh value.
In debug (debugPrint, nothing in a release build) it prints fespalier: dataCache of <name> could not read a saved value, dropped it: <error> and fespalier: dataCache of <name> could not save: <error>.
The key a value is saved under is fespalier:<folder> plus the route's keys in path order as JSON, e.g. fespalier:products/$id[42] (the folder is the data.dart's, relative to the app folder, so it is stable across fsp gen). An enum key is saved by its name, a DateTime as ISO 8601, a list as a list, anything else by its toString(), which is how a custom segment type is spelled in a URL: keep that stable (a web release build that minifies class names would change it).
Don't write an optimistic value with state = on this provider's notifier: Riverpod saves every AsyncData it sets, so the guess would be saved. Keep optimistic values in a layer above the data provider.
Telemetry (since 0.18.0): the data span says whether the saved copy was on screen before the fresh data arrived (fespalier.data.source is cache), or not (fetch). A retry after an offline start still has the copy on screen, so it says cache; once fetched data has replaced the copy, a reload says fetch. Telemetry conventions has the names.
Testing. With no dataCacheStorage override, nothing is saved. To test the cache, share a MemoryDataStorage between two pumpRouter calls, which stands in for a restart:
final storage = MemoryDataStorage();
await pumpRouter(tester, AppRoutes.router(initialLocation: '/products/1'),
overrides: [dataCacheStorage.overrideWithValue(storage)]);
await tester.pumpWidget(const SizedBox());
await pumpRouter(tester, AppRoutes.router(initialLocation: '/products/1'),
overrides: [dataCacheStorage.overrideWithValue(storage), apiProvider.overrideWithValue(OfflineApi())]);
expect(find.text('Coffee beans, 500 g'), findsOneWidget); // the saved product, not error.dartMemoryDataStorage is synchronous, so a test leaves no pending future, and await tester.pump(const Duration(days: 3)) expires a value under the fake clock.
A cache on disk: fespalier_storage
Since 0.9.0. package:fespalier_storage is a tested Storage<String, String> for the dataCache above, on shared_preferences (PrefsDataStorage) or Hive (HiveDataStorage), with a size budget. A route's value is saved when it loads, and at the next start it is on the first frame while the fresh one loads. Add it next to fespalier, with the same url and the same ref (Companion packages says why):
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_storage:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_storage
ref: v0.25.0Open a storage in startup() and give it to dataCacheStorage:
// lib/app/startup.dart
Future<List<Override>> startup() async => [
dataCacheStorage.overrideWithValue(await PrefsDataStorage.open()),
];open() is awaited there, so the first frame is the app: startup() costs one frame behind splash.dart (or the native splash), and read() is then a synchronous lookup, so Riverpod's persist gives the saved value to the first build. A startup() that prefers no extra frame can pass the Future itself (dataCacheStorage.overrideWithValue(PrefsDataStorage.open())): one loading.dart frame, then the saved value. open() returns null (and prints a debug line) when the store cannot open, and dataCacheStorage takes null as "save nothing": a cache never stops an app from starting.
| What | PrefsDataStorage | HiveDataStorage |
|---|---|---|
| Backend | SharedPreferencesWithCache; localStorage on the web | a hive_ce Box<String>; IndexedDB on the web |
| Reads | synchronous | synchronous once the box is open |
| Default budget | 1,000,000 characters, 200 entries | 4,000,000 characters, 1,000 entries |
| Plugins | shared_preferences (most apps have it) | none: hive_ce is pure Dart; path_provider for the cache directory |
| Where on disk | the platform's preferences, beside the app's own keys | getApplicationCacheDirectory() (OS-purgeable, not backed up), none on the web |
| Pick it when | a few small values; on the web localStorage is about 5 MB per origin, shared | more or larger values; opening reads the whole box, so maxSize also bounds startup |
The budget. maxSize is in String.length units (UTF-16 code units, what browsers count localStorage in), keys and headers included; maxEntries is a count. Over either, the entries written longest ago go first, ties by key (no timer, no background sweep). A route's value is written each time it is fetched fresh; reads never write. A value too large for maxSize is not saved: it fails with DataEntryTooLarge, which fespalier prints after could not save, and the route works. Expired and unreadable entries are deleted once, when the storage is made. A full web localStorage is a QuotaExceededError on the write: the entry is dropped and fespalier prints "could not save"; keep maxSize well below, or use Hive.
Versioning, corrupt entries, sign-out.
DataCache(version: '2')is Riverpod'sdestroyKey: stored in the entry's header and compared on read, so another version is deleted, not decoded. The storage's own format is versioned by the entry's first line,fsc1; a later format is read as unreadable, which for a cache means "dropped and loaded again". To drop everything on an app update, callclear(); there is no storage-wide version key.- An unreadable entry (not written by this storage, a truncated one, a value of another type under its key) is dropped at start and counted, and at a read it is a
FormatExceptionthat fespalier prints (dropped it) and deletes. Hive also truncates a corrupt frame when it opens a box. clear()is what a sign-out does: the next start shows nothing from the previous user. It deletes every entry this storage saved, and nothing else. Values in memory are the app's to invalidate (watchauthUserIdin adata.dartthat belongs to the user).- One writer per store. A background isolate that writes the same store makes the in-memory index stale until the next start.
In debug (debugPrint, nothing in a release build), next to fespalier's two lines above:
fespalier_storage: dropped <n> saved entries that could not be read, at startfespalier_storage: could not open shared preferences, so nothing is saved: <error>andfespalier_storage: could not open the Hive box <name>, so nothing is saved: <error>, whenopen()returnsnull- a value over the budget, after fespalier's
could not save::fespalier_storage: the value saved under <key> is <size> characters, more than maxSize (<maxSize>), so it was not saved
A budget of 0 or less throws an ArgumentError (Invalid argument (maxSize): must be more than 0: 0), and so does a SharedPreferencesWithCache with an allowList given to PrefsDataStorage(prefs): the keys of a dataCache are not known in advance, so use PrefsDataStorage.open() (messages).
Testing. package:fespalier_storage/testing.dart has fakePrefsStore([values]), which makes shared_preferences an in-memory store for the test, and memoryBox(), a Hive box in memory for HiveDataStorage(await memoryBox()). Two open()s in one test share the store: that is a restart.
setUp(fakePrefsStore); // also before a test that boots AppMain.run() or AppMain.root(): startup() opens a storage
testWidgets('the saved team is on the first frame of the next start', (tester) async {
await pumpRouter(
tester,
AppRoutes.router(initialLocation: '/teams/acme/members'),
overrides: [dataCacheStorage.overrideWithValue(await PrefsDataStorage.open())],
);
await tester.pumpWidget(const SizedBox()); // the restart
await pumpRouter(
tester,
AppRoutes.router(initialLocation: '/teams/acme/members'),
overrides: [dataCacheStorage.overrideWithValue(await PrefsDataStorage.open())],
settle: false, // one frame, no more
);
expect(find.text('Team ACME'), findsOneWidget); // the saved team, not loading.dart
});Without fakePrefsStore(), open() finds no platform and returns null (a debug line says Bad state: The SharedPreferencesAsyncPlatform instance must be set.): the cache is silently off.
Not built. A storage-wide version key; a byte-exact size (units are String.length); multi-isolate safety; encryption (open your own Hive box with a cipher and pass it to HiveDataStorage(box)).
A Rust core's changes: fespalier_frb
Since 0.13.0. A Rust core behind flutter_rust_bridge (or any other core) usually does two things: it answers calls (core.order(42)) and it says what changed on a Stream (OrderChanged(42), CartCleared). The answers are a data.dart's business; the stream is what keeps them current, through a ChangeFeed and its topics: a data.dart watches a topic, and the watch is the invalidation. Since 0.25.0 the feed, its topics, ChangeBudget, InvalidationTable and FakeChangeSource are fespalier_stream's and documented in Something changed: ChangeFeed; package:fespalier_frb re-exports them (the same declarations, so nothing in an app changes) and keeps what is the core's: starting it (initRustCore) and ChangeCodec. It is a Stream<E> and Riverpod, so it has no flutter_rust_bridge dependency: the generated bindings pin their own runtime exactly (FRB 2.13 needs Dart 3.9.2, which is Flutter 3.35.3, above the 3.35.0 floor), and a second pin could only conflict. Add it next to fespalier, with the same url and the same ref (Companion packages says why); it brings fespalier_stream with it, at the same ref:
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_frb:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_frb
ref: v0.25.0An app that overrides fespalier_frb with a path: (a checkout, a fork) overrides fespalier_stream and fespalier_transport the same way (since 0.25.0), as examples/frb-cose does.
// lib/core/changes.dart
final changes = ChangeFeed<CoreEvent>((ref) => ref.watch(coreProvider).changes(), name: 'core');
/// Rebuilds what watches it when order [id] changes, and on no other event.
final orderChanged = changes.topic<int>((event, id) => event is OrderChanged && event.id == id);
// lib/app/orders/$id/data.dart
Future<Order> data(Ref ref, {required int id}) {
ref.watch(orderChanged(id));
return ref.read(coreProvider).order(id);
}The feed opens the core's stream once per container (a generated stream accepts one listener), a topic moves only on an event that concerns its key, none of a burst is lost, and if open does ref.watch(coreProvider) a restarted core reopens the stream: Topics has the rules, the table form and the tests, and A rebuild budget what a busy core costs.
Starting the core is the app's. RustLib.init belongs to the generated bindings, which a generic package cannot name, so there is no adapter: call it from startup() through initRustCore, which reports one span, fespalier.frb.init, ending ok or error (the attribute fespalier.frb.result; not the error, its text or its stack trace) and rethrows the failure unchanged:
// lib/app/startup.dart
Future<void> startup() => initRustCore(RustLib.init);startup() runs before the router exists, behind splash.dart; a failure there is reported and shown with retry, which runs startup() again. Use ready(container) (App startup) instead of or after it when the core must be read on the app's own container before the first route: startup() has no container and can only return overrides, so a core that is a provider (a database to open from the app's directory, a session to restore) is awaited in ready(), after RustLib.init has run in startup(). The split also decides what a retry repeats: startup() retried runs init again, ready() retried does not. FRB's init throws StateError('Should not initialize flutter_rust_bridge twice') the second time (check the message in your version), so if startup() does other fallible work, do it before initRustCore, and keep everything that comes after the init (opening the store) in ready(). If it cannot be ordered so, guard the retry: if (!RustLib.instance.initialized) await initRustCore(RustLib.init);. The caveat: initialized can already be true when the failure came from the Rust initializer that init runs last, and the guard then skips a core that never started.
A core that sends raw messages: ChangeCodec. Since 0.18.0. A core that sends a StreamSink<String> or a StreamSink<Vec<u8>> of JSON, CBOR, protobuf or bincode (one schema for a Swift or Kotlin shell too), a ReceivePort of plain dart:ffi, or a socket to a sidecar decodes in Dart, where one bad message is skipped instead of ending the stream. ChangeCodec is that decoder, and since 0.25.0 it is a facade over fespalier_stream's EventCodec with the same members (ChangeCodec(decode), ChangeCodec.batch(split, decode), onSkipped, unknownCount, errorCount, decode) and the same rules, that keeps reporting a skip as fespalier.frb.decode (with fespalier.frb.result and fespalier.frb.skipped), never as fespalier.stream.decode:
// lib/core/changes.dart
final coreCodec = ChangeCodec<String, CoreEvent>(
(raw) => eventFromJson(jsonDecode(raw) as Map<String, Object?>), // throws UnknownChange for a newer variant
);
final changes = ChangeFeed<CoreEvent>(
(ref) => ref.watch(coreProvider).rawChanges().transform(coreCodec),
name: 'core',
);The Rust side is #[serde(tag = "type")] on the enum and serde_json::to_string(&event) into a StreamSink<String>. The decoder and its package are yours: none of them is a dependency of fespalier_frb. The two below are fragments (they need your generated classes and your FFI call), not compiled code:
// A protobuf message, with the generated `pb` library of your schema.
CoreEvent eventFromProto(Uint8List bytes) {
final event = pb.Event.fromBuffer(bytes);
return switch (event.whichKind()) {
pb.Event_Kind.orderChanged => OrderChanged(event.orderChanged.id.toInt()), // an int64 is an Int64
pb.Event_Kind.cartCleared => const CartCleared(),
pb.Event_Kind.notSet => throw const UnknownChange(), // an unknown oneof member reads as notSet
};
}
// A ReceivePort fed by plain dart:ffi (allo-isolate): the port is a stream of raw messages.
final port = ReceivePort('core changes');
ref.onDispose(port.close);
ref.watch(coreProvider).subscribe(port.sendPort.nativePort);
return port.transform(ChangeCodec<Object?, CoreEvent>((raw) => eventFromProto(raw as Uint8List)));Testing. package:fespalier_frb/testing.dart re-exports FakeChangeSource (Topics shows it). A widget test never runs startup() (pumpRouter does not), so it needs no RustLib.init and no native library: the fake core is a provider override.
The worked case is examples/frb-cose (since 0.21.1): a Rust core that is CrateStack's client, whose /core page watches coreChanges.any. Its host tests need one thing a Dart app does not: with FRB's native-assets backend a host flutter test builds the core, but RustLib.init() does not find it by its bare name, so test/support/host_core.dart opens the file the build put in build/native_assets/<os>/.
Providers per page instance: fespalier_riverpod
Since 0.13.0. A data.dart is keyed by a location: /c/1 is one provider however many times it is on screen. State that belongs to one page on a navigator (a draft being typed, a selection, a filter sheet's choice, a form step) is not that: /c/1 pushed twice is two pages and should be two states, while /c/1?q=2 after /c/1, or a remount of the page, is the same page and keeps its state. package:fespalier_riverpod keys a provider by the page instance, using the identity core already computes for the route lifecycle (pageInstanceId, the same string as RouteScope.id). It adds no key, command or file kind, starts no timer and listens to nothing. Add it next to fespalier, with the same url and the same ref (Companion packages says why):
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_riverpod:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_riverpod
ref: v0.25.0The key and the providers. A PageInstance<R> is the instance's id plus its typed route, equal by id (the route is not part of equality). pageProvider and pageNotifierProvider are auto-dispose families keyed by it (Riverpod 3 has no separate auto-dispose family type: the results are a ProviderFamily and a NotifierProviderFamily):
// lib/core/chat_draft.dart
class ChatDraft extends Notifier<String> {
ChatDraft(this.page);
final PageInstance<ChatRoute> page;
@override
String build() => '';
void set(String text) => state = text;
}
final chatDraft = pageNotifierProvider<ChatDraft, String, ChatRoute>(ChatDraft.new);
// lib/app/chats/$id/page.dart
class ChatPage extends ConsumerWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final page = PageInstance.of(context, ChatRoute.of); // or usePageInstance(ChatRoute.of) in a hook widget
final draft = ref.watch(chatDraft(page)); // watched, so the state is kept while the page is shown
return Scaffold(
body: Column(
children: [
TextField(onChanged: (text) => ref.read(chatDraft(page).notifier).set(text)),
Text('draft: $draft'),
],
),
);
}
}PageInstance.of(context, ChatRoute.of) reads the page's GoRouterState and the generated XRoute.of; call it in the page or below it. usePageInstance(ChatRoute.of) (a flutter_hooks hook, which fespalier already exports) is the same instance made once per page instance, so a query change gives the same object. The provider keeps the route the key was first made with: what depends on the query belongs to the page's data.dart, which is keyed by the whole location. The state lives as long as something watches it (a read alone does not keep an auto-dispose provider, so the page watches), and is disposed after the page's last widget goes (a pop, a go to another instance).
Keeping it while no widget watches. Riverpod 3 counts a paused subscription, so a parked tab or a covered page that watches its state keeps it without any hold. The hold is for state the page reads but does not watch, or that must outlive its widgets' watches. From an observe.dart, holdForPage ties it to the page's RouteScope (scope.hold under a name that says why); onEnter fires when a page first becomes the top page, so a page under a deep-linked stack is held only once it is on top. It lives from onEnter until the page leaves, and the page and the hook share it because PageInstance.ofScope(scope, route) and PageInstance.of give equal keys for one instance:
// lib/app/chats/$id/observe.dart
void onEnter(Ref ref, {required int id, required RouteScope scope}) {
holdForPage(scope, chatDraft(PageInstance.ofScope(scope, ChatRoute(id: id))));
}Testing. package:fespalier_riverpod/testing.dart has TestPageInstance(route, id:), a key for a test without a router (the default id is 'test'; the same id is the same key). With a router, the instance is what the page builds, so pumpRouter and go/push are the test; in a widget test call await tester.runAsync(container.pump) after a navigation before you count disposals, because Riverpod disposes an unlistened provider in a task of its own.
final one = TestPageInstance(const ChatRoute(id: 1), id: 'first push');
final two = TestPageInstance(const ChatRoute(id: 1), id: 'second push');
expect(container.read(chatDraft(one)), isNot(same(container.read(chatDraft(two)))));What it does not do. It keeps nothing across a restart (a draft that must survive is dataCache or the form drafts of Forms), and a state is per container, so a second ProviderScope has its own.
Typed helpers on the route
A route with a data.dart has three more helpers next to .data and .refresh (and every route below a section with data has preload, below):
final product = ProductRoute.watch(ref, id: 42); // AsyncValue<Product>, for build()
final p = await ProductRoute.read(ref, id: 42); // Future<Product>, for callbacks
final warm = ProductRoute(id: 42).prefetch(ref); // a PrefetchHandle, before navigating
final all = ProductRoute(id: 42).preload(ref); // the same, for everything the page readswatch includes the patches of an optimistic() when the data has any (since 0.8.1).
watch and read are static, and take the keys the provider uses as named arguments (ItemRoute.watch(ref, shop: 'a', id: 1), SearchRoute.watch(ref, q: 'ap', page: 2); none for a route without keys). They can't be instance methods: why.
read keeps the provider alive until it completes, which a plain ref.read(p.future) doesn't for an autoDispose provider. Don't call it from build. On a route with a freshness (since 0.8.1) it returns the value in memory even if it is stale, and starts the reload; refresh waits for the network.
prefetch(ref) starts the load and returns a PrefetchHandle that keeps the provider alive until you call close() on it, so the page you navigate to next shows the value at once. The generated providers are autoDispose, so a prefetch nobody watches would be dropped in the same frame; the handle holds it, and how long is yours to decide. Call it before go, e.g. on hover:
MouseRegion(
onEnter: (_) => _warm = ProductRoute(id: p.id).prefetch(ref),
onExit: (_) => _warm?.close(),
child: ListTile(onTap: () => ProductRoute(id: p.id).go(context), …),
)keepFor:is an optional auto-close:prefetch(ref, keepFor: Duration(seconds: 30))closes the handle after that long. While the widget is alivekeepForholds a timer, so a widget test that uses it shouldpumppast it (or passDuration.zero, which starts the load and keeps nothing).- A failed load isn't kept (the handle closes itself): the page starts a fresh one instead.
- Closing twice is fine, and
handle.isClosedtells. - The subscription also ends when the widget whose
refyou pass is disposed, which closes the handle and cancels itskeepFortimer with it (since 0.5.0), so no timer outlives the widget. Aprefetch(ref)whose handle is dropped lasts as long as the widget behindref.
preload(ref) (since 0.5.0) warms everything the page reads, where prefetch warms the route's own data.dart: the data of each section above it, then its own, the list AppRoutes.dataAt gives for its location, behind one handle that closes them all (see Links). A route with no data at all returns a closed handle; one whose page is deferred (since 0.7.0) also starts loading its code.
Reserved names. Because these are members of the route class, watch, read, prefetch, preload, refresh, ref and keepFor can't be segment or query names, nor (since 0.5.0) can of, maybeOf and copyWith (see the URL as state), nor the helpers of an action.dart (submit, useAction, or an action's own name).
From a location to its data
An app's own prefetch layer often starts from a location (the next page a list points at), not from a route it built by hand. Two generated functions on AppRoutes answer that from the tree, without a table of your own:
final providers = AppRoutes.dataAt(Uri.parse('/products/42'));
// [ProductRoute.data(42)]: the provider the page watches, so warming it warms the page.
final handle = ref.prefetchAll(providers ?? const []); // one PrefetchHandle for them all
// ... later, when your queue's lease ends:
handle.close();dataAt(uri) is a List<ProviderListenable<AsyncValue<Object?>>>?, outermost first: the data.dart of each section above the route, then its own. It is:
nullwhen no route fits the location, or when a segment doesn't parse (/products/abcwhere the id is anint): the rule that showsnot_found.dart;- empty for a route without data (a page, or a catch-all with nothing behind it): a match, with nothing to warm.
The key is built by the parser the route uses, so dataAt(Uri.parse('/products/42')).single == ProductRoute.data(42); for a data.dart that selects a provider it is the selected provider itself (your own productProvider('42')). Query-keyed data is keyed by the location's query (/search?q=ap&page=2 is SearchRoute.data((q: 'ap', page: 2, …)), lists as the QueryList the page's key uses), a catch-all by its decoded path. The mount point (AppRoutes.mount(at: '/shop')) is taken off first and a location outside it is null; each route matches by its own case setting, and a typed catch-all part that does not parse is no match. Nothing else runs: no guard.dart, no redirect.dart, no widget (prefetching what the user asked for is your queue's call, even if a guard sends them elsewhere).
AppRoutes.preload(ref, uri) (since 0.5.0) is ref.prefetchAll(dataAt(uri) ?? const []): one handle for everything the page at uri reads, a closed one when nothing fits or there is nothing to warm. It never navigates and runs no guard. In an app with a deferred route (since 0.7.0) it is matchUrl(uri)?.route.preload(ref) instead, which also starts the page's code.
AppRoutes.match(uri) is what dataAt is a shortcut for (match(uri)?.data). It returns a RouteMatch, or null under the same rules:
final m = AppRoutes.match(Uri.parse('/shops/acme/items/7'))!;
m.info; // the RouteInfo from the manifest: path '/shops/:shop/items/:id', folder, meta, ...
m.params; // {'shop': 'acme', 'id': 7}: the segments and query parameters, parsed
m.route; // ItemRoute(shop: 'acme', id: 7), typed; m.route.location is its canonical spelling
m.data; // the providers, as dataAt returns them
m.uri; // the location it was givenRoutes are tried most specific first (static parts, then :params, then catch-alls), the order go_router uses. match lives on the manifest (AppManifest.match, forwarded by AppRoutes), so with output_manifest: it is in the manifest library; AppRoutes.dataAt and AppRoutes.matchUrl (a UrlMatch: the route, params and data without the RouteInfo) stay in app.g.dart, which never imports a meta.dart. RouteMatch is fespalier's: package:fespalier/fespalier.dart hides go_router's own, so import package:go_router/go_router.dart for that one.
Section data
A folder with a layout.dart and no page.dart (a (group), or a plain folder that only holds routes) can have a data.dart too. It is then the data of the whole section: the layout waits for it, and the layout and the pages below can take it.
// lib/app/teams/$teamId/data.dart
Future<Team> data(Ref ref, {required String teamId}) => …;
// lib/app/teams/$teamId/layout.dart: by type (or a parameter named `data`)
class TeamLayout extends StatelessWidget {
const TeamLayout({super.key, required this.team, required this.child});
final Team team;
final Widget child;
…
}
// lib/app/teams/$teamId/members/page.dart: the page takes it by type as well
class MembersPage extends StatelessWidget {
const MembersPage(this.team, {super.key});
final Team team;
…
}Loading and errors. While the section loads, the nearest
loading.dart(inherited as usual) replaces the layout and the pages inside it, and a failure shows the nearesterror.dartwith itsretry. Nothing below is built until the data is there.Sharing. The layout watches the provider and the pages below read the same one, so
data()runs once however many of them take it, and moving between the section's pages doesn't load it again. When the data reloads (retry, an invalidation), the section keeps showing what it has (keep_previous; withkeep_previous: falseit shows loading again). Which one. A parameter calleddatagets the nearest data: the route's owndata.dart, then the section's, then the next section up. By type, a parameter gets thedata.dartthat yields that type; it is an error if two do (a page's own and a section's, or two sections'), so name the parameterdatafor the nearest or give one of them another type. A page can have its owndata.dartand take a section's by type. Keys. A section'sdata()takes segments (at or above its folder) and, like a page's, query parameters:Future<Report> data(Ref ref, {String? period})keys the section by?period=of the location, which the layout reads from the URL like any layout query parameter. Every route below the section is keyed by it too:periodbecomes a query parameter of each of their typed routes (MonthlyReportRoute(period: '2026-01')writes/reports/monthly?period=2026-01), so the pages read the same provider the layout loaded. A page that declares the same name with another type is an error.Typed handle. A section has no route of its own, so it gets a class named after its folder, with
Sectionon the end (teams/$teamIdisTeamsTeamIdSection,(shop)isShopSection, the app folderRootSection; two folders that name the same class are an error). Its members are static and take the section's keys as named arguments, like a route's:dartTeamsTeamIdSection.data('acme'); // the provider TeamsTeamIdSection.watch(ref, teamId: 'acme'); // AsyncValue<Team> await TeamsTeamIdSection.read(ref, teamId: 'acme'); // Future<Team> final h = TeamsTeamIdSection.prefetch(ref, teamId: 'acme'); // PrefetchHandle await TeamsTeamIdSection.refresh(ref, teamId: 'acme');A key can't be called
ref,keepForor another of the handle's members.Where it applies. A layout of any kind can be a section's, tab layouts included. A
data.dartbeside apage.dartkeeps feeding that page, so the folder that holds the section's layout mustn't have a page.