Oscillation de transition auth avec Riverpod StreamProvider
Problème
Après une connexion réussie et une redirection, l'écran principal (fil d'accueil, tableau de bord, etc.) reste bloqué sur un indicateur de chargement. La redirection auth fonctionne correctement (l'URL affiche /home/0), mais l'écran ne rend jamais les données. Ce n'est PAS un problème de reconstruction rapide — le widget se reconstruit quelques fois puis se stabilise sur un état de chargement.
Contexte / Conditions de déclenchement
Symptômes :
- Écran bloqué sur un spinner de chargement après redirection de connexion réussie
- Barre d'URL / GoRouter affiche le chemin correct (ex.
/home/0) - Le provider de données (ex.
homeFeedProvider) a des données s'il est vérifié directement - Pas de messages d'erreur — juste un chargement permanent
- Peut afficher un bref flash de contenu avant de revenir au chargement
Architecture qui déclenche ceci :
- Un
StreamProviderqui observerouter.routerDelegatepour les changements de localisation - Ce stream analyse les routes en un
RouteContextavec un champtype(home, explore, etc.) - Les providers en aval filtrent sur
routeContext.type == RouteType.homeet retournentAsyncValue.loading()quand le type ne correspond pas - Le widget observe le provider en aval et affiche un indicateur de chargement
Le motif d'oscillation :
Auth state changes → Router redirects to /home/0
→ routerDelegate emits /home/0 ✓
→ routerDelegate emits /welcome/login (stale!) ✗
→ routerDelegate emits /home/0 ✓
→ routerDelegate emits /welcome/* (stale!) ✗
...oscillates for several frames
Pourquoi cela se produit :
Le listener routerDelegate de GoRouter se déclenche pour CHAQUE changement de localisation pendant les transitions, y compris les états intermédiaires/obsolètes. Après la connexion, le routeur traite plusieurs navigations en attente (retirer l'écran de bienvenue, ajouter l'écran d'accueil) et le delegate émet chaque état intermédiaire. Un StreamController synchrone propage ces changements instantanément.
Signature du log :
CTX derive: type=RouteType.home npub=null index=0
CTX derive: type=RouteType.welcome npub=null index=null ← stale!
CTX derive: type=RouteType.home npub=null index=0
Analyse de la cause racine
Le problème est un double filtre sur un stream oscillant :
routerDelegate listener
↓ (emits every location change)
StreamProvider<RouteContext> ← oscillates between /home and /welcome
↓
videosForHomeRouteProvider ← returns loading() when type != home [FILTRE 1]
↓
HomeScreenRouter.build() ← watches pageContext for type check [FILTRE 2]
Quand le stream oscille, les deux filtres s'ouvrent et se ferment rapidement. Le widget finit par rendre l'état de chargement de l'émission qui est arrivée en dernier dans la période de stabilisation.
Solution
Motif : « Je sais qui je suis » — Contourner le filtrage par type de route
Quand un widget connaît son propre contexte (il est monté seulement sur une route spécifique), il n'a pas besoin de filtrer sur un stream de type de route. Il peut lire les informations de route de manière synchrone.
Étape 1 : Lire l'index d'URL de manière synchrone depuis GoRouter
// AVANT : Observer un stream oscillant
final pageContext = ref.watch(pageContextProvider);
return pageContext.when(
data: (ctx) {
if (ctx.type != RouteType.home) return loading();
// ...
},
loading: () => loading(),
error: (e, s) => error(),
);
// APRÈS : Lire de manière synchrone — ce widget EST l'écran d'accueil
final router = ref.read(goRouterProvider);
final location = router.routeInformationProvider.value.uri.toString();
final segments = location.split('/').where((s) => s.isNotEmpty).toList();
int urlIndex = 0;
if (segments.length > 1 && segments[0] == 'home') {
urlIndex = int.tryParse(segments[1]) ?? 0;
}
Étape 2 : Observer le provider de données directement
// AVANT : Observer le provider intermédiaire qui filtre sur le type de route
final videosAsync = ref.watch(videosForHomeRouteProvider);
// APRÈS : Observer le provider de données directement — pas de filtre par type de route nécessaire
final videosAsync = ref.watch(homeFeedProvider);
Étape 3 : Supprimer les imports inutilisés de providers intermédiaires
Nettoyer les imports pour tous les providers intermédiaires de filtrage par route qui ne sont plus utilisés.
Quand ne PAS appliquer ce correctif
- Quand le widget a besoin de rendre différent contenu selon le type de route (ex. un shell partagé qui affiche différents fils)
- Quand le widget est monté sur plusieurs routes et doit changer de comportement
- Si le problème est réellement des reconstructions rapides (utilisez
riverpod-infinite-rebuild-loopà la place)
Vérification
Après le correctif :
- Connexion → redirection vers accueil → fil d'accueil se charge immédiatement (pas de spinner permanent)
- Pas d'avertissements
RAPID REBUILD(ce correctif n'en cause pas) - Faire défiler le fil fonctionne normalement
- Le glisser-pour-actualiser fonctionne
- Naviguer loin et revenir — le fil se charge toujours
- Tester avec
ref.watch(homeFeedProvider)dans initState pour confirmer que les données arrivent
Exemple : correctif complet (HomeScreenRouter)
Avant (bloqué sur chargement) :
@override
Widget build(BuildContext context) {
final pageContext = ref.watch(pageContextProvider);
return buildAsyncUI(
pageContext,
onData: (ctx) {
if (ctx.type != RouteType.home) {
return const Center(child: BrandedLoadingIndicator(size: 80));
}
final videosAsync = ref.watch(videosForHomeRouteProvider);
return buildAsyncUI(videosAsync, ...);
},
);
}
Après (se charge correctement) :
@override
Widget build(BuildContext context) {
// Read URL synchronously — HomeScreenRouter is only at /home/:index
final router = ref.read(goRouterProvider);
final location = router.routeInformationProvider.value.uri.toString();
final segments = location.split('/').where((s) => s.isNotEmpty).toList();
int urlIndex = 0;
if (segments.length > 1 && segments[0] == 'home') {
urlIndex = int.tryParse(segments[1]) ?? 0;
}
// Watch data directly — no route-type gate needed
final videosAsync = ref.watch(homeFeedProvider);
return buildAsyncUI(videosAsync, onData: (state) { ... });
}
Notes
- Distinct des boucles de reconstruction :
riverpod-infinite-rebuild-loopcouvre les reconstructions rapides (50+ par seconde). Ce problème cause 3-8 reconstructions qui se stabilisent sur un état DE CHARGEMENT. - Lié au timing auth : Coexiste souvent avec une redirection synchrone du routeur qui a besoin de données qui ne sont pas encore disponibles. Voir le correctif complémentaire : pré-récupérer les données avant de définir l'état auth pour que les redirections aient ce dont elles ont besoin.
- StreamProvider vs read : L'oscillation n'affecte que
StreamProviderobservant l'état réactif du routeur.ref.read()de la localisation courante de GoRouter est stable. - routerDelegate de GoRouter : Ce listener se déclenche pour chaque état de navigation intermédiaire. Il est fiable pour les états finaux mais oscille pendant les transitions multi-étapes (connexion → retirer bienvenue → ajouter accueil).
- Technique de débogage : Ajouter
print('CTX derive: type=${ctx.type}')au StreamProvider pour voir le motif d'oscillation.
Compétences associées
riverpod-infinite-rebuild-loop— reconstructions rapides dues à des problèmes watch/listenerflutter-pageview-url-routing-reorder-loop— boucle infinie due au suivi de réorganisation d'élémentsflutter-startup-network-blocking— opérations réseau bloquantes au démarrage