riverpod-infinite-rebuild-loop

Par divinevideo · divine-mobile

Déboguez et corrigez les boucles infinies de reconstruction de widgets dans les applications Flutter utilisant la gestion d'état Riverpod. À utiliser lorsque : (1) Les logs affichent des avertissements "RAPID REBUILD" ou 50+ reconstructions par seconde, (2) L'UI devient non réactive ou affiche le même contenu en boucle, (3) Le bug n'affecte que les appareils lents ou les connexions réseau dégradées, (4) Le watch de provider crée des dépendances circulaires avec le routeur/l'état URL. Couvre la surutilisation de `ref.watch()`, la redondance watch+listener, et les chaînes de dépendances transitives.

npx skills add https://github.com/divinevideo/divine-mobile --skill riverpod-infinite-rebuild-loop

Boucle de reconstruction infinie Riverpod

Problème

Les widgets Flutter utilisant Riverpod entrent dans une boucle de reconstruction infinie, causant :

  • 50+ reconstructions de widgets en quelques secondes
  • L'interface utilisateur devient non réactive
  • Le même contenu s'affiche à répétition
  • Le bug se manifeste principalement sur les appareils plus lents ou les connexions réseau faibles

Contexte / Conditions de déclenchement

Symptômes dans les logs :

⚠️ RAPID REBUILD #54! Only 15ms since last build
⚠️ RAPID REBUILD DETECTED! Only 6ms since last build

Symptômes rapportés par l'utilisateur :

  • « Boucle de défilement infini » - la même vidéo/le même contenu continue d'apparaître
  • « L'application gèle » ou devient non réactive
  • « Fonctionne sur mon téléphone mais pas sur les appareils plus anciens »

Scénarios de déclenchement :

  1. Plusieurs providers asynchrones se terminent à des moments décalés
  2. Provider qui surveille l'état de la route/URL ET met à jour l'URL en build
  3. Utilisation de ref.watch() ET ajout d'écouteurs manuels au même provider
  4. Observations transitives : Widget observe A, A observe B, Widget observe aussi B

Analyse de la cause racine

Pattern 1 : Boucle de rétroaction de mise à jour d'URL

// MAUVAIS : Crée une boucle infinie
Widget build(BuildContext context) {
  final pageContext = ref.watch(pageContextProvider);  // Observe l'URL
  final videos = ref.watch(videosProvider);

  // Détecte le changement de position vidéo et « silencieusement » met à jour l'URL
  if (currentVideoIndex != urlIndex) {
    context.go('/home/$currentVideoIndex');  // Le changement d'URL déclenche une reconstruction !
  }
}

Boucle : Les vidéos se réorganisent → URL mise à jour → pageContextProvider émet → reconstruction → les vidéos se réorganisent peut-être à nouveau → répétition

Pattern 2 : Redondance Watch + Listener

// MAUVAIS : La double-souscription cause des reconstructions doubles
final cache = ref.watch(cacheProvider);  // Watch déclenche une reconstruction
cache.addListener(onCacheChanged);        // L'écouteur DÉCLENCHE AUSSI une action

Pattern 3 : Dépendances d'observation transitive

// MAUVAIS : Double-observation de la même source
Widget build() {
  ref.watch(pageContextProvider);           // Observation #1
  ref.watch(derivedProvider);               // derivedProvider OBSERVE AUSSI pageContextProvider !
}

Pattern 4 : Chargement de provider asynchrone décalé

// PROBLÉMATIQUE sur les appareils lents : Chaque observation déclenche une reconstruction à la fin du provider
final a = ref.watch(asyncProviderA);  // Se termine à T=100ms → reconstruction
final b = ref.watch(asyncProviderB);  // Se termine à T=200ms → reconstruction
final c = ref.watch(asyncProviderC);  // Se termine à T=350ms → reconstruction
final d = ref.watch(asyncProviderD);  // Se termine à T=500ms → reconstruction
// Sur les appareils rapides : tous se terminent ~simultanément, 1-2 reconstructions
// Sur les appareils lents : fin échelonnée, 4+ reconstructions

Solution

Étape 1 : Audit de l'utilisation de ref.watch()

Pour chaque ref.watch() dans les méthodes build, demandez-vous :

  • Ce provider change-t-il fréquemment ?
  • Ai-je besoin de RECONSTRUIRE quand il change, ou seulement de RÉAGIR ?
  • Suis-je aussi en train d'écouter manuellement ce provider ?

Méthodes Riverpod : | Méthode | Comportement | |---------|----------| | ref.watch() | Souscrivez + RECONSTRUISEZ au changement | | ref.read() | Lisez une fois, PAS de reconstruction | | ref.listen() | Souscrivez + callback, PAS de reconstruction |

Étape 2 : Convertir les observations inutiles

// AVANT : Reconstructions à chaque changement
final videoService = ref.watch(videoServiceProvider);

// APRÈS : Lire une fois, utiliser un écouteur pour les réactions
final videoService = ref.read(videoServiceProvider);
ref.listen(videoServiceProvider, (prev, next) {
  // Réagir aux changements sans reconstruire
  if (next.hasNewVideos) refreshUI();
});

Étape 3 : Supprimer la redondance Watch + Listener

// AVANT : Double-souscription
final cache = ref.watch(cacheProvider);
cache.addListener(onCacheChanged);

// APRÈS : Choisir une approche
// Option A : Juste observer (si la reconstruction est nécessaire)
final cache = ref.watch(cacheProvider);

// Option B : Lire + écouter (si la reconstruction n'est pas nécessaire)
final cache = ref.read(cacheProvider);
ref.listen(cacheProvider, (_, __) => onCacheChanged());

Étape 4 : Casser les boucles de mise à jour d'URL

// AVANT : Mise à jour d'URL en build cause une boucle
if (currentVideoIndex != urlIndex) {
  context.go('/home/$currentVideoIndex');
}

// APRÈS : Ne pas mettre à jour l'URL sur réorganisation du contenu
// Juste tracker la position avec PageController, pas l'URL
// OU utiliser l'ID du contenu dans l'URL au lieu de l'index : /home/video/abc123

Étape 5 : Lot de charge initiale

// AVANT : Observer chaque provider asynchrone (N reconstructions sur les appareils lents)
final a = ref.watch(asyncA);
final b = ref.watch(asyncB);

// APRÈS : Créer un provider combiné qui attend tous les éléments
@riverpod
Future<CombinedState> combinedState(Ref ref) async {
  final a = await ref.watch(asyncA.future);
  final b = await ref.watch(asyncB.future);
  return CombinedState(a, b);
}
// Le Widget n'observe que le provider combiné (1 reconstruction)

Vérification

Après les corrections :

  1. Exécutez l'application sur un appareil lent ou utilisez la limitation du réseau
  2. Vérifiez les logs pour les avertissements de reconstruction - devrait voir ≤5 reconstructions au démarrage
  3. Faites défiler/naviguez et vérifiez que l'interface utilisateur reste réactive
  4. Aucun avertissement « RAPID REBUILD » dans les logs

Exemple : Correction complète

Avant (problématique) :

class _HomeScreenState extends ConsumerState<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    final pageContext = ref.watch(pageContextProvider);
    final videos = ref.watch(videosProvider);  // Observe aussi pageContextProvider en interne !

    // Boucle de mise à jour d'URL
    if (videos.currentIndex != pageContext.index) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        context.go('/home/${videos.currentIndex}');
      });
    }

    return PageView(...);
  }
}

Après (corrigé) :

class _HomeScreenState extends ConsumerState<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    // Observer uniquement les vidéos, lire le contexte de page
    final pageContext = ref.read(pageContextProvider).requireValue;
    final videos = ref.watch(videosProvider);

    // Ne pas mettre à jour l'URL sur réorganisation - tracker avec PageController uniquement
    // L'URL ne change que sur navigation utilisateur explicite

    return PageView(...);
  }
}

Notes

  • Ce bug est dépendant du timing - peut ne pas se reproduire sur les appareils rapides

  • Testez sur des appareils physiques avec limitation du réseau pour détecter les problèmes

  • Ajoutez la journalisation de détection de reconstruction pendant le développement :

    static int _buildCount = 0;
    static DateTime? _lastBuild;
    
    @override
    Widget build(BuildContext context) {
      final now = DateTime.now();
      if (_lastBuild != null && now.difference(_lastBuild!).inMilliseconds < 100) {
        Log.warning('RAPID REBUILD #${++_buildCount}!');
      }
      _lastBuild = now;
      // ...
    }

Références

Skills similaires