equatable-linkedhashmap-lru-reorder

Par divinevideo · divine-mobile

Corrige les classes d'état Flutter/bloc bornées par LRU qui perdent silencieusement les mises à jour d'ordre d'insertion. À utiliser quand : (1) une classe d'état utilise `Equatable` avec un champ `LinkedHashMap<K, V>` pour préserver l'ordre d'insertion (caches LRU, MRU, éléments récents), (2) `Cubit.emit` semble ignorer des émissions lorsqu'une clé existante est réinsérée ou déplacée vers la position la plus récente, (3) l'éviction LRU supprime le « mauvais » élément après un rafraîchissement, (4) un test qui signale la même clé deux fois puis dépasse la capacité constate que l'entrée rafraîchie est évincée au lieu de la plus ancienne. Cause racine : la comparaison de maps par défaut d'Equatable est structurelle (non ordonnée), donc un réordonnancement sans modification des clés/valeurs produit un état « égal » que `Cubit.emit` supprime.

npx skills add https://github.com/divinevideo/divine-mobile --skill equatable-linkedhashmap-lru-reorder

Equatable + LinkedHashMap suppression de la réorganisation LRU

Problème

Une classe d'état Flutter bloc/cubit utilise une LinkedHashMap<K, V> ordonnée par insertion pour implémenter la sémantique LRU (accès-touche, éviction du plus ancien passé une limite). Le champ est inclus dans props pour l'égalité des valeurs. Quand une clé existante est réinsérée pour la déplacer vers la plus récente, Cubit.emit abandonne le nouvel état comme égal au précédent, laissant l'ordre LRU obsolète. À l'insertion suivante passant la limite, l'entrée « touchée » est évincée au lieu de la vraiment la plus ancienne.

C'est silencieux : aucune erreur, aucun log. Les tests qui ne vérifient que l'état final de chaque clé passent. Le bug ne fait surface que quand un test affirme spécifiquement qu'une clé rafraîchie survit à une éviction ultérieure de débordement de limite.

Contexte / Conditions de déclenchement

  • La classe d'état extends Equatable avec un champ LinkedHashMap<K, V>
  • Comportement LRU implémenté via remove(key) + insertion pour déplacer-vers-plus-récent
  • props inclut la map directement : List<Object?> get props => [_map, ...]
  • Symptômes :
    • blocTest(..., expect: () => hasLength(N)) signale moins d'émissions que prévu
    • Test d'éviction LRU comme « rafraîchir la clé A, puis déborder — s'attendre à l'éviction de B » échoue avec A évincée à la place
    • Les consommateurs utilisant BlocListener ou context.select ne réagissent pas aux mises à jour de toucher seul

Solution

Ajoutez la liste des clés à props aux côtés de la map pour que les changements d'ordre d'insertion produisent un état distinct :

@override
List<Object?> get props {
  // Les deux entrées sont requises.
  //
  // La comparaison map par défaut d'Equatable est structurelle (sans ordre), donc
  // `_statuses` seul détecte les changements de valeur mais PAS les pur réordonnancements LRU
  // où l'ensemble clé/valeur est inchangé.
  // `_statuses.keys.toList()` détecte ces changements d'ordre d'insertion.
  // Supprimer l'un ou l'autre supprimerait silencieusement toute une classe de mises à jour d'état
  // — ne « simplifiez » pas ceci.
  return [_statuses, _statuses.keys.toList(), maxEntries];
}

Gardez la map dans props aussi — elle détecte les changements de valeur seule (même clé, valeur différente, pas de réordonnancement). La liste des clés détecte les changements d'ordre seul. Ensemble, ils couvrent les deux dimensions. Supprimer l'un ou l'autre rouvre le bug.

Renforcement supplémentaire (recommandé)

Pendant que vous êtes ici, faites aussi :

  1. Copie défensive dans le constructeur s'il accepte une LinkedHashMap construite en externe. Un appelant peut sinon conserver une référence et muter l'état « immuable » :

    VideoPlaybackStatusState({
      this.maxEntries = _defaultMaxEntries,
      LinkedHashMap<K, V>? statuses,
    }) : _statuses = statuses == null
              ? LinkedHashMap<K, V>()
              : LinkedHashMap<K, V>.from(statuses);
  2. Court-circuitez les écritures redondantes dans le cubit pour éviter d'allouer une nouvelle map à chaque rapport sans-op (par exemple errorBuilder qui tire chaque frame lors d'une relance) :

    void report(K key, V value) {
      if (state.valueFor(key) == value) return;
      emit(state.withValue(key, value));
    }

Vérification

Écrivez un test explicite d'inégalité des props qui épingle l'invariant directement, plutôt que de compter sur des tests LRU de plus haut niveau pour le détecter transitivement :

test('states with same entries but different LRU order are not equal', () {
  final a = MyState()
      .withStatus(idA, Status.foo)
      .withStatus(idB, Status.bar);
  final b = MyState()
      .withStatus(idB, Status.bar)
      .withStatus(idA, Status.foo);

  expect(a, isNot(equals(b)));
});

Test de mutation : revenez temporairement à props: [_map, maxEntries] (sans liste de clés) et exécutez le test. Il DOIT échouer. Restaurez et confirmez le passage. Cela prouve que l'assertion est porteuse de charge et défend l'invariant.

Exemple

Forme complète de la classe d'état (Dart) :

import 'dart:collection';
import 'package:equatable/equatable.dart';

class VideoPlaybackStatusState extends Equatable {
  VideoPlaybackStatusState({
    this.maxEntries = _defaultMaxEntries,
    LinkedHashMap<String, PlaybackStatus>? statuses,
  }) : _statuses = statuses == null
            ? LinkedHashMap<String, PlaybackStatus>()
            : LinkedHashMap<String, PlaybackStatus>.from(statuses);

  static const int _defaultMaxEntries = 100;
  final int maxEntries;
  final LinkedHashMap<String, PlaybackStatus> _statuses;

  PlaybackStatus statusFor(String id) =>
      _statuses[id] ?? PlaybackStatus.ready;

  VideoPlaybackStatusState withStatus(String id, PlaybackStatus status) {
    final next = LinkedHashMap<String, PlaybackStatus>.from(_statuses)
      ..remove(id)
      ..[id] = status;
    while (next.length > maxEntries) {
      next.remove(next.keys.first);
    }
    return VideoPlaybackStatusState(maxEntries: maxEntries, statuses: next);
  }

  @override
  List<Object?> get props => [_statuses, _statuses.keys.toList(), maxEntries];
  //                         ^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^
  //                         détecte     détecte les réordonnancements
  //                         les changements (touches LRU)
  //                         de valeur
}

Le test LRU qui détecte le bug :

test('reporting same id twice moves it to most-recent', () {
  final cubit = VideoPlaybackStatusCubit(maxEntries: 2);
  cubit.report(id1, PlaybackStatus.forbidden);
  cubit.report(id2, PlaybackStatus.ageRestricted);
  cubit.report(id1, PlaybackStatus.forbidden); // refresh id1
  cubit.report(id3, PlaybackStatus.notFound);  // overflow

  // Sans le correctif : id1 est évincée (faux — elle vient d'être touchée)
  // Avec le correctif : id2 est évincée (correct — c'est la plus ancienne)
  expect(cubit.state.statusFor(id2), PlaybackStatus.ready);
  expect(cubit.state.statusFor(id1), PlaybackStatus.forbidden);
});

Notes

  • Interaction avec lint Flutter : utiliser un littéral explicite LinkedHashMap<K, V>() déclenche prefer_collection_literals (le lint veut {}). Mais {} type as Map<K, V>, pas LinkedHashMap, ce qui perd la garantie de compile-time que l'ordre d'insertion est préservé à travers les refactors. Gardez le type explicite et supprimez le lint localement (// ignore: prefer_collection_literals) si le chemin du constructeur le déclenche.
  • S'applique à toute collection ordonnée dans Equatable : Queue, SplayTreeMap, ou toute structure où l'ordre est sémantique. Partout où Equatable compare les contenus de la structure sans ordre mais votre code dépend de l'ordre, vous avez besoin d'une entrée props secondaire qui capture l'ordre.
  • Non spécifique à bloc : le même bug apparaît avec les vérifications == du state notifier Riverpod et tout diffing basé sur l'égalité. Le même correctif s'applique.
  • Discipline TDD : ce bug n'a été attrapé que parce qu'un test affirmait spécifiquement « la clé rafraîchie survit au débordement ». Une suite de tests qui ne vérifie que « le statut X correspond à la clé Y » après des écritures individuelles passerait avec le bug intact. Quand vous écrivez des tests pour un état de collection ordonnée, incluez toujours au moins un test qui exerce l'ordonnancement lui-même (pas seulement l'appartenance).

Références

Skills similaires