Un utilisateur clique sur A, puis B. Le titre indique B, mais l’éditeur affiche A. Les deux lectures ont renvoyé les bonnes données. Le résultat de A est simplement arrivé après le changement de sélection, sans garde empêchant son affichage.
Une chaîne clic, chargement, rendu n’explique pas le bug : les deux clics suivent les mêmes fonctions. Il faut savoir quand chaque requête commence et finit, et laquelle peut actualiser l’écran. Ce billet détaille un petit exemple JavaScript, avec les états d’erreur et de chargement souvent oubliés.
Lire jusqu’au premier await
Voici un lecteur simplifié. load renvoie une promesse du texte, et publish fusionne un correctif dans l’état visible. Les deux sont fournis en paramètres, sans dépendance à un framework d’interface :
export function createUnsafeReader(load, publish) {
return async function select(file) {
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
publish({ text });
} catch (error) {
publish({ error: error.message });
} finally {
publish({ loading: false });
}
};
}
Avant await, la fonction enregistre la sélection et efface l’ancien texte. Pendant l’attente, un autre select peut commencer. Après await, rien ne vérifie si cette requête correspond encore à l’écran.
La variable locale file ne change pas pendant la suspension, mais la vue partagée oui. C’est tout le bug : les données sont correctes pour cet appel, mais cet appel n’est plus celui à afficher.
Tracer la chronologie
Utilisez deux lectures contrôlables et terminez B avant A :
| Étape | Événement | Fichier choisi | Texte visible | Chargement |
|---|---|---|---|---|
| 1 | Sélection de A ; lecture A | A | vide | oui |
| 2 | Sélection de B ; lecture B | B | vide | oui |
| 3 | B réussit | B | texte B | non |
| 4 | A réussit | B | texte A | non |
Les deux appels ont exécuté le même code. L’ordre a fait gagner la dernière lecture terminée, alors que la dernière sélection devait l’emporter.
Avant de corriger, vérifiez le comportement attendu. Un aperçu unique doit montrer la dernière sélection. Un import par lot exige tous les résultats, et une file de sauvegarde peut exiger l’ordre des écritures ; ignorer les anciens résultats serait faux dans les deux cas. La correction vise une vue où une sélection remplace l’autre.
Reproduire sans minuteurs
Un test attendant 100 ms pour A et 10 ms pour B est lent et dépend de l’ordonnancement. Une promesse différée laisse le test choisir exactement quand chaque lecture se termine :
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
Créez deux lectures différées, sélectionnez les deux fichiers, résolvez B puis A :
const a = deferred(),
b = deferred();
const state = {};
const select = createUnsafeReader(
(file) => (file === 'A' ? a : b).promise,
(patch) => Object.assign(state, patch),
);
const first = select('A'),
second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // Reproduces the defect.
La dernière assertion attend volontairement le résultat cassé pour montrer le bug. Le test corrigé attend le texte de B. Séparer reproduction et correction évite de confondre une reproduction verte avec la preuve que l’ancien code fonctionne.
Numéroter chaque sélection
Pour une vue unique, un compteur suffit. Incrémentez-le au début, conservez sa valeur dans l’appel et comparez-la avant toute publication :
export function createReader(load, publish) {
let latest = 0;
return async function select(file) {
const request = ++latest;
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
if (request === latest) publish({ text });
} catch (error) {
if (request === latest) publish({ error: error.message });
} finally {
if (request === latest) publish({ loading: false });
}
};
}
Le compteur appartient à cette instance. Global, un clic dans une fenêtre supprimerait des résultats d’une autre. Créé dans select, il serait réinitialisé à chaque appel et ne distinguerait rien.
Comparer les chemins ne suffit pas. Avec A, puis B, puis A, les première et troisième requêtes ont le même chemin, mais la première peut porter une ancienne version. Le numéro de requête les distingue.
L’exemple suppose que publish actualise l’état synchroniquement et que load rejette avec un Error. Si le framework regroupe les mises à jour ou démonte les composants, ou si l’API rejette autre chose, gérez aussi ces cas.
Tester A → B → A avec trois lectures distinctes
Donnez une promesse différée à chaque invocation. La fixture précédente associe A à une promesse et B à une autre ; la réutiliser pour un second A cacherait la différence entre première et troisième requêtes.
Supposons que load puisse renvoyer différents instantanés pour un même chemin, par exemple après une édition. Le test fournit explicitement « A version 1 » et « A version 2 ». Ce sont des exemples contrôlés, pas un incident Yenpo. Si votre chargeur renvoie toujours le même instantané immuable, aucun texte différent n’apparaîtra ; vérifiez cache et lecture avant d’appliquer cette explication.
Le téléchargement inclut un createPathOnlyReader volontairement incomplet. Il suit selectedFile et protège succès, erreur et finally avec file === selectedFile. Les trois gardes acceptent encore le premier A dès qu’A est resélectionné :
| Événement | Garde par chemin | Garde par numéro |
|---|---|---|
| Sélection de A, B, puis A | Chemin actuel A | Requête actuelle 3 |
| La troisième renvoie A version 2 | Affiche A version 2 | Affiche A version 2 |
| B revient | Ignore B | Ignore la requête 2 |
| La première renvoie A version 1 | Affiche A version 1 | Garde A version 2 |
Les tests appariés exécutent cet ordre sur les deux lecteurs. Le test reproduce: attend la mauvaise version pour prouver l’échec du chemin ; celui par numéro attend la version 2. Deux autres terminent le premier A pendant l’attente du troisième : un ancien succès doit laisser le texte vide et le chargement actif ; un ancien échec ne doit pas remplir l’erreur. L’échec du A actuel doit toujours apparaître.
Garder deux lecteurs indépendants
Créez un lecteur par vue indépendante, avec sa propre fermeture latest et sa cible publish. Réutilisez-le pour les sélections suivantes de la vue ; le recréer à chaque clic perd le compteur partagé qui invalide les anciens appels.
Le dernier test crée des lecteurs gauche et droit avec chargeurs différés et états séparés, puis suit cette séquence :
- Sélectionner A à gauche, A à droite, puis B à gauche. Le numéro gauche vaut 2 ; le droit reste 1.
- Résoudre A à droite pendant l’attente de B à gauche. La droite affiche le résultat et cesse de charger ; la gauche reste vide et charge.
- Rejeter l’ancien A gauche. La gauche doit attendre B sans erreur.
- Résoudre B à gauche. La gauche affiche B et la droite garde son A.
Déplacer latest au niveau du module fait échouer l’étape 2 : la sélection gauche plus récente invalide la requête droite, qui reste en chargement. Partager l’état visible briserait aussi l’isolation malgré des compteurs séparés. Le test vérifie les deux vues après ces fins entrelacées.
Erreurs et chargement peuvent aussi arriver au mauvais endroit
Protéger seulement publish({ text }) retire le mauvais texte, mais laisse deux bugs :
- A échoue après la sélection de B. Son
catchaffiche « A failed » dans la vue de B. - A termine pendant que B charge. Son
finallyéteint l’indicateur de B.
Pour détecter le second, terminez A pendant l’attente de B et vérifiez immédiatement que loading reste true. Regarder seulement après B ne montre jamais l’arrêt prématuré.
Pour le premier, rejetez A pendant B et vérifiez l’absence d’erreur et le chargement actif, puis résolvez B. Ajoutez un test où la requête actuelle échoue : une garde avalant toutes les erreurs passerait le test ancien tout en cachant les vrais échecs.
La règle dépasse donc « le bon texte finit par apparaître » : seule la requête actuelle peut publier quoi que ce soit à sa fin, texte, erreur ou arrêt de l’indicateur.
Annuler n’est pas ignorer
Si le chargeur permet l’annulation, annuler A économise du travail. Mais A peut encore publier : résultat déjà en cache, signal ignoré ou fin simultanée à l’annulation.
Utilisez l’annulation pour arrêter le travail inutile, et le numéro pour décider si un résultat peut toucher la vue. Vérifiez que le chargeur respecte réellement un AbortSignal ; toutes les API à promesses ne le font pas.
Ignorer un ancien résultat n’annule pas ce qu’a fait load en chemin. Ici, le chargeur renvoie seulement du texte. S’il écrit aussi dans un cache partagé ou un modèle, tracez ces écritures séparément. Le compteur protège uniquement la vue.
Exécuter les tests
Téléchargez async-selection.mjs et lancez-le avec Node.js 22 ou ultérieur :
node --test async-selection.mjs
Les dix tests couvrent l’écrasement A → B, les gardes de succès/erreur/chargement, A → B → A et deux lecteurs indépendants. Les deux tests reproduce: attendent volontairement les résultats cassés ; les huit autres vérifient le comportement voulu. Sans minuteurs ni dépendances.
Dans un vrai projet, trouvez le gestionnaire d’entrée, chaque await ou rappel, l’état partagé changeant pendant l’attente et chaque écriture au retour. Notez l’ordre à côté des emplacements source.
Dans Yenpo, gardez le fichier d’entrée ouvert et demandez à l’AI Reader de suivre l’état à travers un await. Vérifiez sa réponse sur les chemins de succès, erreur et finally. S’il s’arrête à « la promesse se résout », demandez quelle requête a le droit d’utiliser le résultat.