riverpod-stream-provider-auth-transition-oscillation

Par divinevideo · divine-mobile

Correction de l'écran d'accueil / flux principal Flutter bloqué sur le spinner de chargement après la connexion lors de l'utilisation d'un `StreamProvider` Riverpod qui surveille les changements de localisation GoRouter. À utiliser quand : (1) L'écran affiche un `BrandedLoadingIndicator` ou un `CircularProgressIndicator` de façon permanente après une redirection d'authentification réussie, (2) Un widget surveille un provider de type gate-par-route qui retourne `AsyncValue.loading()` de façon intermittente, (3) Les logs montrent que la localisation de route oscille entre un chemin obsolète et le chemin courant lors de la transition post-connexion (p. ex. `/welcome/*` après `/home/0`), (4) La chaîne de providers a un double-gate : le widget est conditionné par `pageContext` ET le provider de données est lui aussi conditionné par `pageContext`. Distinct de `riverpod-infinite-rebuild-loop` (rebuilds rapides) — ceci provoque un état de chargement permanent, et non des rebuilds infinis.

npx skills add https://github.com/divinevideo/divine-mobile --skill riverpod-stream-provider-auth-transition-oscillation

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 :

  1. Un StreamProvider qui observe router.routerDelegate pour les changements de localisation
  2. Ce stream analyse les routes en un RouteContext avec un champ type (home, explore, etc.)
  3. Les providers en aval filtrent sur routeContext.type == RouteType.home et retournent AsyncValue.loading() quand le type ne correspond pas
  4. 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 :

  1. Connexion → redirection vers accueil → fil d'accueil se charge immédiatement (pas de spinner permanent)
  2. Pas d'avertissements RAPID REBUILD (ce correctif n'en cause pas)
  3. Faire défiler le fil fonctionne normalement
  4. Le glisser-pour-actualiser fonctionne
  5. Naviguer loin et revenir — le fil se charge toujours
  6. 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-loop couvre 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 StreamProvider observant 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/listener
  • flutter-pageview-url-routing-reorder-loop — boucle infinie due au suivi de réorganisation d'éléments
  • flutter-startup-network-blocking — opérations réseau bloquantes au démarrage

Skills similaires