Dépannage
Activer le mode debug en premier
Activer debugMode avant toute investigation. Qora journalise les hits de cache, miss de cache, écritures de persistance, sauts d'hydratation et erreurs de sérialiseur avec le préfixe [Qora].
final client = QoraClient( // ou PersistQoraClient
config: const QoraClientConfig(debugMode: true),
);
Persistance et hydratation
Les données ne sont pas persistées entre les redémarrages
Symptôme : PersistQoraClient est configuré, mais l'application démarre toujours avec un état de chargement et sans données en cache.
Cause : Aucun QoraSerializer<T> n'est enregistré pour le type récupéré. Qora met les données en cache en mémoire mais ne les persiste pas sur disque.
// Enregistrer avant d'appeler hydrate()
client.registerSerializer<List<Post>>(
QoraSerializer(
toJson: (posts) => posts.map((p) => p.toJson()).toList(),
fromJson: (json) => (json as List).map(Post.fromJson).toList(),
),
name: 'List<Post>',
);
await client.hydrate();
Avec debugMode: true, Qora journalise l'événement de persistance ignoré pour les types non enregistrés.
hydrate() s'exécute mais le cache est toujours vide
Cause : registerSerializer est appelé après hydrate(). Lors de l'hydratation, Qora résout les sérialiseurs depuis les valeurs typeName stockées. Les entrées sans sérialiseur correspondant sont ignorées.
// Mauvais ordre
await client.hydrate();
client.registerSerializer<User>(...); // trop tard. Les entrées User sont ignorées
// Ordre correct
client.registerSerializer<User>(...);
await client.hydrate();
runApp(...);
Le cache est chaud mais les widgets affichent encore un spinner à la première frame
Cause : hydrate() n'est pas attendu avant runApp. L'arbre de widgets est monté avant la complétion de l'hydratation.
// hydrate() se déclenche mais n'est pas attendu
void main() {
client.hydrate(); // pas d'await
runApp(QoraScope(client: client, child: const MyApp()));
}
// Attendre avant runApp
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await storage.init();
await client.hydrate();
runApp(QoraScope(client: client, child: const MyApp()));
}
L'hydratation échoue après un build Flutter Web ou --obfuscate
Cause : Sans name explicite, registerSerializer utilise T.toString() comme discriminant de type. L'obfuscation change cette valeur. Les entrées persistées d'un mode de build ne correspondent pas aux discriminants d'un autre mode.
// Non sûr. Le nom devient 'a' sous obfuscation.
client.registerSerializer<User>(userSerializer);
// Stable dans tous les modes de build
client.registerSerializer<User>(userSerializer, name: 'User');
client.registerSerializer<List<Post>>(postListSerializer, name: 'List<Post>');
Le name du sérialiseur doit rester stable entre les versions de l'app. Un name modifié invalide les entrées précédemment persistées pour ce type.
fromJson plante au démarrage après un changement de modèle
Cause : Un champ requis a été introduit entre les versions de l'app. Le JSON persisté des versions précédentes n'inclut pas le champ, et fromJson lève une exception.
PersistQoraClient attrape les exceptions fromJson lors de l'hydratation, supprime l'entrée invalide et continue l'exécution. L'entrée est perdue et la prochaine lecture déclenche un fetch réseau.
Implémenter un parsing défensif pour réduire la perte de données :
factory User.fromJson(dynamic json) {
final map = json as Map<String, dynamic>;
return User(
id: map['id'] as int,
name: map['name'] as String,
// Ajouté en v2. Valeur par défaut pour les entrées persistées par v1.
role: map['role'] as String? ?? 'viewer',
);
}
Pour les changements cassants tels que les changements de type de champ ou la suppression de champ sans valeur par défaut sûre, changer le name du sérialiseur de 'User' à 'User_v2'. Qora ignore les anciennes entrées et réécrit les données fraîches après le prochain fetch réussi.
État des requêtes
La requête est bloquée dans Initial et ne fetche jamais
Cause A : enabled: false est configuré sur QoraBuilder ou QoraOptions. Le widget observe l'état mais ne déclenche pas de fetch.
// Vérifier cette configuration :
QoraBuilder<User>(
enabled: someVariable, // false maintient l'état à Initial
...
)
Cause B : QoraStateBuilder est utilisé à la place de QoraBuilder. QoraStateBuilder est en observation seule et n'appelle jamais fetchQuery.
La requête est bloquée dans Loading indéfiniment
Cause A : dependsOn est configuré, et la requête de dépendance n'atteint pas Success. watchQuery attend la résolution de la dépendance avant d'exécuter le fetch dépendant.
// Valider les déclarations de dépendance et de clé.
client.watchQuery<Profile>(
key: ['profile'],
fetcher: () {
final auth = client.getQueryData<Auth>(['auth'])!;
return api.getProfile(auth.userId);
},
options: const QoraOptions(dependsOn: ['auth']),
);
Cause B : fetchQuery est utilisé avec dependsOn. fetchQuery lève StateError quand les dépendances ne sont pas résolues. Utiliser watchQuery pour la gestion réactive des dépendances.
Cause C : NetworkMode.online est actif sans ConnectivityManager. Dans cette configuration, les fetches s'exécutent toujours. Avec FlutterConnectivityManager et sans connectivité, les requêtes transitent vers Loading(previousData: null) avec FetchStatus.paused et reprennent automatiquement après la reconnexion.
La requête ne refetche pas quand l'app revient au premier plan
Cause : FlutterLifecycleManager n'est pas fourni à QoraScope.
// FlutterLifecycleManager manquant
QoraScope(client: client, child: const MyApp())
// Correct
QoraScope(
client: client,
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
child: const MyApp(),
)
Vérifier également que refetchOnWindowFocus: true reste activé dans QoraClientConfig.defaultOptions.
La requête ne refetche pas quand la connectivité est rétablie
Cause : FlutterConnectivityManager n'est pas fourni à QoraScope.
// Correct
QoraScope(
client: client,
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
)
FlutterConnectivityManager requiert connectivity_plus dans pubspec.yaml. L'absence de la dépendance cause une erreur d'import à la compilation.
Cache et clés
Des widgets différents voient des données différentes pour la "même" requête
Cause : Les clés de requête ne sont pas structurellement équivalentes entre les sites d'appel. Qora utilise l'égalité structurelle profonde (KeyEquality). Des types d'exécution différents dans les segments de clé produisent des entrées de cache distinctes.
// Types de clé incohérents
fetchQuery(key: ['users', 42], ...); // int
watchQuery(key: ['users', '42'], ...); // String. Clé différente.
// Cohérent
fetchQuery(key: ['users', 42], ...);
watchQuery(key: ['users', 42], ...);
QoraBuilder se ré-abonne à chaque rebuild (problème de performance)
Cause : queryKey est créé inline comme une nouvelle instance de liste non-const. QoraBuilder.didUpdateWidget compare les références, donc chaque rebuild apparaît comme une nouvelle clé et déclenche un ré-abonnement.
// Nouvelle instance de List à chaque build
QoraBuilder<List<Post>>(
queryKey: ['posts', page], // nouvelle instance de liste à chaque build
...
)
// Référence stable. Change uniquement quand page change.
QoraBuilder<List<Post>>(
queryKey: const ['posts'], // pour les clés statiques
// ou
queryKey: ['posts', page], // correct SI page est stable (pas rebuilté à chaque frame)
...
)
Si la clé inclut des variables, mettre à jour ces variables uniquement quand le dataset demandé change.
dataOrNull retourne null même après un fetch réussi
Cause : Le paramètre de type sur QoraBuilder<T> ne correspond pas au type de données récupéré. Le stream d'état est fortement typé. Un Success<User> pour la clé ['users', 1] n'est pas délivré à QoraBuilder<Profile> sur la même clé.
// Incompatibilité de type. L'état est Success<User> mais le builder attend Profile.
QoraBuilder<Profile>(queryKey: ['users', userId], ...)
// Correct
QoraBuilder<User>(queryKey: ['users', userId], ...)
Mises à jour optimistes
Le rollback ne fonctionne pas et l'UI reste dans l'état optimiste après l'échec
Cause : Le snapshot a été pris après setQueryData au lieu d'avant.
// Le snapshot est pris après l'écriture optimiste. Il capture le mauvais état.
client.setQueryData<User>(key, optimisticUser);
final snapshot = client.getQueryData<User>(key); // déjà optimiste : incorrect !
// Toujours prendre le snapshot en premier
final snapshot = client.getQueryData<User>(key); // T?, null si pas de données précédentes
client.setQueryData<User>(key, optimisticUser);
try {
await api.updateUser(...);
} catch (_) {
client.restoreQueryData<User>(key, snapshot); // null → removeQuery ; sinon setQueryData
}
Utilisation de setQueryData pour le rollback au lieu de restoreQueryData
Cause : setQueryData<T>(key, data) requiert un T non-null. Il produit toujours un état Success. Quand l'état pré-mutation ne contenait pas de données (Initial), il n'y a pas de T à passer, et appeler setQueryData laisse un état Success incorrect dans le cache.
restoreQueryData<T>(key, T? data) accepte la valeur T? retournée par getQueryData<T>. Quand data est null, il appelle removeQuery(key), restaurant Initial. Quand data est non-null, il appelle setQueryData<T>(key, data). Cela gère à la fois le cas "avait des données" et "n'avait pas de données" avec un seul appel.
final snapshot = client.getQueryData<User>(key); // T?, null si l'entrée était vide
client.setQueryData<User>(key, optimisticUser);
try {
await api.updateUser(...);
} catch (_) {
// Erreur de compilation quand snapshot est null ; incorrect quand l'état précédent était Initial.
// client.setQueryData<User>(key, snapshot!);
// Gère null correctement : supprime l'entrée quand il n'y avait pas de données précédentes.
client.restoreQueryData<User>(key, snapshot);
}
Intégration Flutter
context.qora lève FlutterError: QoraScope not found
Cause : Aucun ancêtre QoraScope n'existe au-dessus du widget qui appelle context.qora (ou QoraScope.of(context)).
// QoraScope doit être au-dessus de l'arbre de widgets
runApp(
QoraScope(
client: client,
child: const MyApp(), // context.qora fonctionne partout à l'intérieur de MyApp
),
);
Vérifier également que context.qora n'est pas appelé depuis des callbacks exécutés après la destruction du widget. context.qora doit être résolu à l'intérieur du sous-arbre appartenant à QoraScope.
File de mutations hors ligne
Les mutations ne sont pas rejouées après la reconnexion
Cause A : FlutterConnectivityManager n'est pas passé à QoraScope. Qora ne reçoit pas les événements de reconnexion.
Cause B : offlineQueue: true n'est pas défini dans MutationOptions. La mise en file hors ligne est opt-in par mutation.
QoraMutationBuilder<void, Todo, void>(
mutator: api.createTodo,
options: MutationOptions(
offlineQueue: true, // requis pour mettre en file quand hors ligne
),
...
)
Cause C : la mutation vient de useMutation avec qora_hooks 1.3.1, qui ne branche pas la file hors-ligne. Passez à la 1.3.1 ou plus.
Une mutation en file a disparu après la reconnexion
Cause : le serveur l'a rejetée pendant le rejeu. Une mutation en file est envoyée une seule fois ; un rejeu en échec n'est pas remis en file, et le rejeu continue avec la suivante.
Résolution : annuler dans onError (il reçoit le contexte de onMutate, y compris pour les mutations en file) et prévenir l'utilisateur à cet endroit, par exemple avec un snackbar et une action pour réessayer.
Requêtes infinies
fetchNextPage() est appelé mais aucune nouvelle donnée ne se charge
Cause : getNextPageParam retourne null sur la dernière page, donc hasNextPage devient false. Qora ne fetche pas de pages supplémentaires quand la pagination est complète.
// Pages entières. Problème courant : retourne null trop tôt.
getNextPageParam: (lastPage, allPages) =>
lastPage.items.isEmpty ? null : allPages.length + 1,
// Comportement correct : retourner null uniquement quand le dataset est complet.
// Pagination par curseur. Problème courant : mauvais champ de curseur.
getNextPageParam: (lastPage, _) => lastPage.nextCursor,
// Si nextCursor est "" au lieu de null, hasNextPage reste true.
// Correction : lastPage.nextCursor?.isNotEmpty == true ? lastPage.nextCursor : null
Le feed affiche des éléments en double après refetch()
Cause : Pagination par numéro de page entier combinée avec des insertions côté serveur. Quand de nouveaux posts sont ajoutés entre page=1 et page=2, re-fetcher page=1 retourne des éléments différents du premier chargement, causant un chevauchement.
Correction : Utiliser la pagination par curseur. Les positions de curseur restent stables quand de nouveaux enregistrements sont insérés.
// Pages entières. Instable avec les insertions côté serveur.
InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (_, pages) => pages.length + 1,
)
// Pagination par curseur. Stable entre les insertions.
InfiniteQueryOptions<FeedPage, String?>(
initialPageParam: null,
getNextPageParam: (last, _) => last.nextCursor,
)