From 31deb4ce86a4a4b4e22ac8ff36ce88159752c4f8 Mon Sep 17 00:00:00 2001 From: donfreddy Date: Wed, 15 Jul 2026 06:04:26 +0100 Subject: [PATCH] docs: add route-level prefetching guide with GoRouter and AutoRoute patterns (#25) --- docs/content/en/04.recipes/12.prefetch.md | 296 ++++++++++++++++++++++ docs/content/fr/04.recipes/12.prefetch.md | 295 +++++++++++++++++++++ packages/dart/CHANGELOG.md | 4 + packages/flutter/CHANGELOG.md | 4 + 4 files changed, 599 insertions(+) create mode 100644 docs/content/en/04.recipes/12.prefetch.md create mode 100644 docs/content/fr/04.recipes/12.prefetch.md diff --git a/docs/content/en/04.recipes/12.prefetch.md b/docs/content/en/04.recipes/12.prefetch.md new file mode 100644 index 0000000..0687815 --- /dev/null +++ b/docs/content/en/04.recipes/12.prefetch.md @@ -0,0 +1,296 @@ +--- +title: Route-Level Prefetching +description: Preload query data before a screen mounts using GoRouter redirects, AutoRoute resolvers, or QoraPrefetch: eliminating loading spinners during navigation. +navigation: + icon: i-lucide-navigation +seo: + title: Route-Level Prefetching - Qora + description: Prefetch Qora query data before a screen mounts with GoRouter and AutoRoute patterns. Eliminate loading spinners during navigation with cache-first or network-first strategies. +--- + +Route-level prefetching ensures data is already cached by the time a user navigates to a new screen. This guide covers GoRouter redirect-based prefetching, AutoRoute resolver-based prefetching, error handling strategies, and cache-first vs network-first trade-offs. + +--- + +## Core API + +Qora provides three prefetch primitives, listed from most declarative to most imperative: + +| Primitive | When to use | +| --- | --- | +| `QoraPrefetch` (widget) | Wrapping a child that is already in the tree (e.g. hover, focus, scroll-into-view). | +| `context.prefetch()` (extension) | Imperative trigger from a gesture callback or route event. | +| `client.prefetch()` (core) | Any Dart context without a widget tree (e.g. route guards, resolvers). | + +All three are no-ops when fresh data already exists for the given `queryKey`. Prefetch runs once per key; repeat calls are safe and cheap. + +```dart [lib/features/user_detail_screen.dart] +// Widget-based: wrap a child that implies "likely to navigate" +MouseRegion( + onEnter: (_) => setState(() => _prefetch = true), + child: _prefetch + ? QoraPrefetch( + queryKey: ['user', userId], + fetcher: () => api.getUser(userId), + child: UserListTile(userId: userId), + ) + : UserListTile(userId: userId), +) + +// Imperative: call from any callback +GestureDetector( + onLongPress: () => context.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ), + child: UserTile(userId), +) +``` + +--- + +## GoRouter: Redirect-Based Prefetching + +GoRouter supports `redirect` callbacks that run before a route is activated. Use them to prefetch data synchronously (fire-and-forget), then let the route guard proceed immediately. By the time the screen widget mounts, its `QoraBuilder` reads from cache with zero network wait. + +:::tip +The `redirect` callback runs synchronously. Prefetch is fire-and-forget: do **not** `await` the prefetch, or you will block the navigation transition. +::: + +### Implementation + +```dart [lib/app_router.dart] +import 'package:go_router/go_router.dart'; +import 'package:qora_flutter/qora_flutter.dart'; + +final _client = QoraClient(); + +final appRouter = GoRouter( + routes: [ + GoRoute( + path: '/users', + builder: (_, __) => const UserListScreen(), + routes: [ + GoRoute( + path: ':id', + redirect: (context, state) { + // Prefetch user detail while the list is still visible. + // The redirect runs synchronously; navigation proceeds immediately. + final userId = state.pathParameters['id']!; + _client.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ).ignore(); // fire-and-forget + return null; // proceed to the route + }, + builder: (_, state) { + final userId = state.pathParameters['id']!; + return UserDetailScreen(userId: userId); + }, + ), + ], + ), + ], +); +``` + +### With error handling + +Prefetch failures should never block navigation. Wrap the fetcher in a try/catch and log errors for observability: + +```dart [lib/app_router.dart] +redirect: (context, state) { + final userId = state.pathParameters['id']!; + _client.prefetch( + key: ['user', userId], + fetcher: () async { + try { + return await api.getUser(userId); + } catch (e, s) { + log('Prefetch failed for user $userId', error: e, stackTrace: s); + rethrow; // still captured by Qora's Failure state + } + }, + ).ignore(); + return null; +}, +``` + +--- + +## AutoRoute: Resolver-Based Prefetching + +AutoRoute uses `RouteGuard` or an `onNavigation` callback inside the router delegate. A resolver-based approach fires prefetch before the resolver returns the route. The route renders only after the prefetch is dispatched: + +```dart [lib/app_router.dart] +import 'package:auto_route/auto_route.dart'; +import 'package:qora_flutter/qora_flutter.dart'; + +final _client = QoraClient(); + +@AutoRouterConfig() +class AppRouter extends RootStackRouter { + @override + List get routes => [ + AutoRoute(page: UserListRoute.page, path: '/users', children: [ + AutoRoute( + page: UserDetailRoute.page, + path: ':id', + guards: [PrefetchGuard()], + ), + ]), + ]; +} + +class PrefetchGuard extends AutoRouteGuard { + @override + Future onNavigation( + NavigationResolver resolver, + StackRouter router, + ) async { + final userId = resolver.route.pathParams.getString('id'); + if (userId != null) { + _client + .prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ) + .ignore(); + } + // Never block: resolve immediately, data loads in background. + resolver.next(); + } +} +``` + +### With a timeout fallback + +If you want to wait a short grace period (e.g. 200 ms) to give the prefetch a head start before resolving, but never block longer than that: + +```dart [lib/guards/prefetch_guard.dart] +class PrefetchGuard extends AutoRouteGuard { + @override + Future onNavigation( + NavigationResolver resolver, + StackRouter router, + ) async { + final userId = resolver.route.pathParams.getString('id'); + if (userId != null) { + final prefetch = _client.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ); + // Wait at most 200 ms for the prefetch to settle. + try { + await prefetch.timeout(const Duration(milliseconds: 200)); + } catch (_) { + // Timeout: resolver still proceeds, data loads on-screen. + } + } + resolver.next(); + } +} +``` + +--- + +## Strategy: Cache-First vs Network-First + +| Strategy | Behaviour | Use when | +| --- | --- | --- | +| **Cache-first** (default) | Prefetch is a no-op if fresh data exists. First navigation after stale expiry triggers a fetch. | Data freshness window is known and acceptable. | +| **Network-first** | Always fetch on navigation, bypassing the cache staleness check. | Data must be guaranteed fresh on every visit (e.g. financial dashboards). | + +### Cache-first (default) + +The `client.prefetch()` method already implements cache-first: it checks the cache and returns immediately if fresh data exists. No additional configuration needed. + +### Network-first + +Force a fetch by calling `fetchQuery` directly or by invalidating the key before prefetching: + +```dart [lib/app_router.dart] +redirect: (context, state) { + final userId = state.pathParameters['id']!; + // Always fetch, even if fresh data exists. + _client.invalidate(['user', userId]); + _client.fetchQuery( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ).ignore(); + return null; +}, +``` + +For a reusable pattern, set `staleTime` to `Duration.zero` in the query options so every `QoraBuilder` mount triggers a fresh fetch: + +```dart [lib/features/user_detail_screen.dart] +QoraBuilder( + queryKey: ['user', userId], + fetcher: () => api.getUser(userId), + options: const QoraOptions(staleTime: Duration.zero), + builder: (context, state, _) { ... }, +) +``` + +--- + +## Using `QoraPrefetch` Inside a Route + +If you cannot (or prefer not to) modify your routing layer, embed `QoraPrefetch` at the widget level. This is the simplest approach when the navigable child is already in the tree: + +```dart [lib/features/user_list_screen.dart] +ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return QoraPrefetch( + queryKey: ['user', user.id], + fetcher: () => api.getUser(user.id), + child: ListTile( + title: Text(user.name), + onTap: () => context.push('/users/${user.id}'), + ), + ); + }, +) +``` + +Prefetch fires on mount (when the list item appears) and re-fires if `queryKey` changes. By the time the user taps, data is cached. + +--- + +## Testing Prefetch Logic + +Use `QoraClient` directly in tests without any routing framework: + +```dart [test/user_prefetch_test.dart] +import 'package:qora/qora.dart'; +import 'package:test/test.dart'; + +void main() { + test('prefetch populates cache before screen mounts', () async { + final client = QoraClient(); + + await client.prefetch( + key: ['user', 1], + fetcher: () async => User(id: 1, name: 'Alice'), + ); + + // Screen widget now mounts and reads from cache. + final state = client.getQueryState(['user', 1]); + expect(state, isA>()); + expect((state as Success).data.name, 'Alice'); + }); +} +``` + +--- + +## Next Steps + +- [QoraClient.prefetch](../api-reference/qora-client.md): API reference for the core prefetch method +- [Best Practices](../flutter-integration/best-practices.md): Additional prefetch patterns (hover, focus, scroll-into-view) +- [Dependent Queries](./dependent-queries.md): Chain queries that depend on prefetched data +- [Cancellation](./cancel-token.md): Cancel in-flight prefetches on rapid navigation +- [Testing](./testing.md): Write deterministic tests for prefetch logic diff --git a/docs/content/fr/04.recipes/12.prefetch.md b/docs/content/fr/04.recipes/12.prefetch.md new file mode 100644 index 0000000..282ea1b --- /dev/null +++ b/docs/content/fr/04.recipes/12.prefetch.md @@ -0,0 +1,295 @@ +--- +title: Prefetch au Niveau des Routes +description: Précharger les données de requête avant qu'un écran ne monte avec les redirections GoRouter, les résolveurs AutoRoute ou QoraPrefetch: éliminant les spinners de chargement pendant la navigation. +navigation: + icon: i-lucide-navigation +seo: + title: Prefetch au Niveau des Routes - Qora + description: Préchargez les données Qora avant qu'un écran ne s'affiche avec GoRouter et AutoRoute. Supprimez les spinners de chargement avec les stratégies cache-first ou network-first. +--- + +Le prefetch au niveau des routes garantit que les données sont déjà en cache au moment où l'utilisateur navigue vers un nouvel écran. Ce guide couvre le prefetch par redirection GoRouter, par résolveur AutoRoute, les stratégies de gestion d'erreurs et les compromis entre cache-first et network-first. + +--- + +## API principale + +Qora fournit trois primitives de prefetch, de la plus déclarative à la plus impérative : + +| Primitive | Quand l'utiliser | +| --- | --- | +| `QoraPrefetch` (widget) | Envelopper un enfant déjà dans l'arbre (survol, focus, défilement). | +| `context.prefetch()` (extension) | Déclenchement impératif depuis un callback de geste ou événement de route. | +| `client.prefetch()` (cœur) | Tout contexte Dart sans arbre de widgets (gardes de route, résolveurs). | + +Les trois sont sans effet quand des données fraîches existent déjà pour la `queryKey` donnée. Le prefetch s'exécute une fois par clé ; les appels répétés sont sûrs et peu coûteux. + +```dart [lib/features/user_detail_screen.dart] +// Widget : envelopper un enfant qui implique "susceptible de naviguer" +MouseRegion( + onEnter: (_) => setState(() => _prefetch = true), + child: _prefetch + ? QoraPrefetch( + queryKey: ['user', userId], + fetcher: () => api.getUser(userId), + child: UserListTile(userId: userId), + ) + : UserListTile(userId: userId), +) + +// Impératif : appeler depuis n'importe quel callback +GestureDetector( + onLongPress: () => context.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ), + child: UserTile(userId), +) +``` + +--- + +## GoRouter : Prefetch par Redirection + +GoRouter prend en charge les callbacks `redirect` qui s'exécutent avant qu'une route ne soit activée. Utilisez-les pour précharger les données de manière synchrone (fire-and-forget), puis laissez la garde de route continuer immédiatement. Lorsque le widget d'écran monte, son `QoraBuilder` lit depuis le cache sans attente réseau. + +:::tip +Le callback `redirect` s'exécute de manière synchrone. Le prefetch est de type fire-and-forget : **ne pas** `await` le prefetch, ou vous bloquerez la transition de navigation. +::: + +### Implémentation + +```dart [lib/app_router.dart] +import 'package:go_router/go_router.dart'; +import 'package:qora_flutter/qora_flutter.dart'; + +final _client = QoraClient(); + +final appRouter = GoRouter( + routes: [ + GoRoute( + path: '/users', + builder: (_, __) => const UserListScreen(), + routes: [ + GoRoute( + path: ':id', + redirect: (context, state) { + // Précharger le détail utilisateur pendant que la liste est visible. + final userId = state.pathParameters['id']!; + _client.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ).ignore(); // fire-and-forget + return null; // continuer vers la route + }, + builder: (_, state) { + final userId = state.pathParameters['id']!; + return UserDetailScreen(userId: userId); + }, + ), + ], + ), + ], +); +``` + +### Avec gestion d'erreurs + +Les échecs de prefetch ne doivent jamais bloquer la navigation. Enveloppez le fetcher dans un try/catch et logguez les erreurs pour l'observabilité : + +```dart [lib/app_router.dart] +redirect: (context, state) { + final userId = state.pathParameters['id']!; + _client.prefetch( + key: ['user', userId], + fetcher: () async { + try { + return await api.getUser(userId); + } catch (e, s) { + log('Échec du prefetch pour user $userId', error: e, stackTrace: s); + rethrow; // toujours capturé par l'état Failure de Qora + } + }, + ).ignore(); + return null; +}, +``` + +--- + +## AutoRoute : Prefetch par Résolveur + +AutoRoute utilise `RouteGuard` ou un callback `onNavigation` dans le délégué du routeur. Une approche par résolveur déclenche le prefetch avant que le résolveur ne retourne la route. La route s'affiche seulement après que le prefetch est envoyé : + +```dart [lib/app_router.dart] +import 'package:auto_route/auto_route.dart'; +import 'package:qora_flutter/qora_flutter.dart'; + +final _client = QoraClient(); + +@AutoRouterConfig() +class AppRouter extends RootStackRouter { + @override + List get routes => [ + AutoRoute(page: UserListRoute.page, path: '/users', children: [ + AutoRoute( + page: UserDetailRoute.page, + path: ':id', + guards: [PrefetchGuard()], + ), + ]), + ]; +} + +class PrefetchGuard extends AutoRouteGuard { + @override + Future onNavigation( + NavigationResolver resolver, + StackRouter router, + ) async { + final userId = resolver.route.pathParams.getString('id'); + if (userId != null) { + _client + .prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ) + .ignore(); + } + // Ne jamais bloquer : résoudre immédiatement, les données chargent en arrière-plan. + resolver.next(); + } +} +``` + +### Avec un délai de grâce + +Si vous voulez attendre un court délai (ex. 200 ms) pour donner une avance au prefetch avant de résoudre, sans jamais bloquer plus longtemps : + +```dart [lib/guards/prefetch_guard.dart] +class PrefetchGuard extends AutoRouteGuard { + @override + Future onNavigation( + NavigationResolver resolver, + StackRouter router, + ) async { + final userId = resolver.route.pathParams.getString('id'); + if (userId != null) { + final prefetch = _client.prefetch( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ); + // Attendre au maximum 200 ms que le prefetch se termine. + try { + await prefetch.timeout(const Duration(milliseconds: 200)); + } catch (_) { + // Timeout: le résolveur continue, les données chargent à l'écran. + } + } + resolver.next(); + } +} +``` + +--- + +## Stratégie : Cache-First vs Network-First + +| Stratégie | Comportement | Quand l'utiliser | +| --- | --- | --- | +| **Cache-first** (défaut) | Le prefetch est sans effet si des données fraîches existent. La première navigation après expiration déclenche un fetch. | La fenêtre de fraîcheur des données est connue et acceptable. | +| **Network-first** | Toujours fetcher à la navigation, en ignorant le contrôle de péremption du cache. | Les données doivent être garanties fraîches à chaque visite (ex. tableaux de bord financiers). | + +### Cache-first (par défaut) + +La méthode `client.prefetch()` implémente déjà le cache-first : elle vérifie le cache et retourne immédiatement si des données fraîches existent. Aucune configuration supplémentaire nécessaire. + +### Network-first + +Forcer un fetch en appelant `fetchQuery` directement ou en invalidant la clé avant le prefetch : + +```dart [lib/app_router.dart] +redirect: (context, state) { + final userId = state.pathParameters['id']!; + // Toujours fetcher, même si des données fraîches existent. + _client.invalidate(['user', userId]); + _client.fetchQuery( + key: ['user', userId], + fetcher: () => api.getUser(userId), + ).ignore(); + return null; +}, +``` + +Pour un pattern réutilisable, définissez `staleTime` à `Duration.zero` dans les options de requête pour que chaque montage de `QoraBuilder` déclenche un fetch frais : + +```dart [lib/features/user_detail_screen.dart] +QoraBuilder( + queryKey: ['user', userId], + fetcher: () => api.getUser(userId), + options: const QoraOptions(staleTime: Duration.zero), + builder: (context, state, _) { ... }, +) +``` + +--- + +## Utiliser `QoraPrefetch` dans une Route + +Si vous ne pouvez pas (ou préférez ne pas) modifier votre couche de routage, intégrez `QoraPrefetch` au niveau du widget. C'est l'approche la plus simple quand l'enfant navigable est déjà dans l'arbre : + +```dart [lib/features/user_list_screen.dart] +ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return QoraPrefetch( + queryKey: ['user', user.id], + fetcher: () => api.getUser(user.id), + child: ListTile( + title: Text(user.name), + onTap: () => context.push('/users/${user.id}'), + ), + ); + }, +) +``` + +Le prefetch se déclenche au montage (quand l'élément de liste apparaît) et se redéclenche si `queryKey` change. Au moment où l'utilisateur tape, les données sont en cache. + +--- + +## Tester la Logique de Prefetch + +Utilisez `QoraClient` directement dans les tests sans aucun framework de routage : + +```dart [test/user_prefetch_test.dart] +import 'package:qora/qora.dart'; +import 'package:test/test.dart'; + +void main() { + test('le prefetch remplit le cache avant le montage de l\'écran', () async { + final client = QoraClient(); + + await client.prefetch( + key: ['user', 1], + fetcher: () async => User(id: 1, name: 'Alice'), + ); + + // Le widget d'écran monte maintenant et lit depuis le cache. + final state = client.getQueryState(['user', 1]); + expect(state, isA>()); + expect((state as Success).data.name, 'Alice'); + }); +} +``` + +--- + +## Étapes Suivantes + +- [QoraClient.prefetch](../api-reference/qora-client.md): Référence API pour la méthode de prefetch cœur +- [Bonnes Pratiques](../flutter-integration/best-practices.md): Patterns de prefetch supplémentaires (survol, focus, défilement) +- [Requêtes Dépendantes](./dependent-queries.md): Chaîner des requêtes qui dépendent de données prefetchées +- [Annulation](./cancel-token.md): Annuler les prefetchs en vol lors de navigations rapides +- [Tests](./testing.md): Écrire des tests déterministes pour la logique de prefetch diff --git a/packages/dart/CHANGELOG.md b/packages/dart/CHANGELOG.md index 2a7a95e..4ee0633 100644 --- a/packages/dart/CHANGELOG.md +++ b/packages/dart/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Documentation + +- Route-level prefetching guide covering GoRouter redirects, AutoRoute resolvers, and cache-first vs network-first strategies with error handling patterns (#25). + ## [1.2.0] - 2026-06-15 ### Added diff --git a/packages/flutter/CHANGELOG.md b/packages/flutter/CHANGELOG.md index a3fc26f..780e80d 100644 --- a/packages/flutter/CHANGELOG.md +++ b/packages/flutter/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Documentation + +- Route-level prefetching guide covering GoRouter redirects, AutoRoute resolvers, cache-first vs network-first strategies, and error handling patterns (#25). + ## [1.2.0] - 2026-06-15 ### Added