Sources externes : le scroll infini boucle au lieu de s'arrêter + revue de la couverture en recherche vide #38
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Contexte
Deux demandes liées, dans le flux "parcourir une source externe" (onglets
RecipeSourcesPanel.tsx, apps/web/src/features/recipes/sources/) :proprement une fois le catalogue épuisé.
façon de remonter le maximum de recettes quand la recherche est vide
("parcourir tout") ?
Deux explorations dédiées (front / back) ont permis de localiser précisément
la cause du bug plutôt que de la supposer, et de caractériser le comportement
réel de chaque source en recherche vide plutôt que de deviner (y compris une
vérification en direct de Marmiton, non couverte par l'exploration initiale).
Partie 1 — Le bug de bouclage
Cause exacte
apps/web/src/features/recipes/sources/SourceItemTable.tsx:49-68— l'effetqui crée l'
IntersectionObservera pour unique dépendance[hasMore]:onLoadMoreen est délibérément exclu (biome-ignorejustifié par "éviterun réabonnement inutile à chaque rendu").
hasMore(browseState.nextCursor !== null,RecipeSourcesPanel.tsx:316)reste
truesur toute la durée d'un scroll qui progresse normalement —l'observer n'est donc créé qu'une seule fois et capture pour toujours
la référence
onLoadMore(=handleLoadMore,RecipeSourcesPanel.tsx:214-255)de ce rendu-là. Or
handleLoadMoreest une fonction re-déclarée à chaquerendu (pas de
useCallback), qui figebrowseState.nextCursorparvaleur au moment de sa création :
Séquence du bug : après le tout premier chargement automatique
(closure fraîche, fonctionne), chaque intersection suivante ré-exécute la
même closure figée, qui redemande le même curseur périmé — jamais la
vraie valeur courante de
browseState.nextCursor. Le mergeitems: [...prev.items, ...items](RecipeSourcesPanel.tsx:241) n'ayantaucune déduplication, la liste visible s'allonge en boucle avec les mêmes
éléments.
nextCursorne peut alors mécaniquement jamais atteindre le vrainullfinal via ce chemin,hasMorene repasse jamais àfalse, lasentinelle (rendue tant que
hasMore,SourceItemTable.tsx:110-114) restedans le DOM, et l'observer — jamais recréé — continue de se déclencher :
boucle infinie.
Fait notable : l'effet de préchargement "une page d'avance"
(
nextPagePrefetchRef,RecipeSourcesPanel.tsx:183-212) a lui un tableau dedépendances correct et complet (
[browseState, sourceKey, debouncedSearch])— il avance bien vers le vrai curseur courant à chaque fois. Mais ses
résultats ne sont jamais consommés par le scroll automatique bloqué sur
l'ancienne closure : il tourne dans le vide en parallèle du bug.
Pourquoi aucun test ne l'a vu
Il faut au moins 3 pages pour que le bug se manifeste (le tout premier
déclenchement automatique utilise toujours une closure fraîche — c'est le
second déclenchement automatique, qui suppose qu'un premier chargement a
déjà réussi, qui utilise la closure périmée). Le seul scénario Cypress
existant (
recipe-sources.feature, "Loads further pages automatically, withno load more button") ne simule que 2 pages, et son mock détecte "c'est
la dernière page" via
req.url.includes("cursor=")plutôt que la valeurréelle du curseur — il ne peut donc pas distinguer "a bien avancé de la page
2 à la page 3" de "a redemandé la page 2 en boucle".
Fix recommandé
Pattern "latest ref" — garde
[hasMore]comme seule dépendance (donc pas deréabonnement inutile, l'intention d'origine du
biome-ignoreresterespectée), mais l'observer appelle toujours la closure la plus fraîche :
Garde-fou complémentaire (répond directement à "je veux garder l'auto
scroll tant que de nouvelles recettes sont pas affichées") — dans
handleLoadMore(RecipeSourcesPanel.tsx), dédupliquer le merge parexternalIdet traiter "cette page n'a ajouté aucun élément réellementnouveau" comme un arrêt local, même si le serveur a renvoyé un
nextCursornon nul. Protège aussi contre un futur souci côté backend (l'heuristique de
fin de liste d'un adaptateur qui se tromperait), pas seulement contre ce bug
précis.
Test à ajouter : le mock Cypress doit simuler ≥3 pages réelles avec des
curseurs distincts effectivement vérifiés (pas juste "contient cursor=") —
sinon la même régression pourrait revenir sans qu'aucun test ne la voie,
comme cette fois.
Partie 2 — Maximiser les recettes par source en recherche vide
search.php?s=(vide) → échantillon fixe (~25 repas),nextCursorcodé en dur ànull(the-meal-db.ts:110)recherche.aspx?aqt=(vide) — vérifié en direct : renvoie une vraie page de résultats (recettes les mieux notées : "Blanquette de veau", "La Vraie Moussaka Grecque"...), pagine ensuite via&page=Ndernieres-recettes.htm?page=N— pagine réellement ; commentaire du code : vérifié en direct jusqu'à la page 500 avec du contenu distinct à chaque foispage=N+hasMorePagesrenvoyé par le serveur lui-mêmeSeul vrai problème identifié : TheMealDB. Son
list()n'a aucune branchedédiée à la requête vide — il tape
search.php?s=comme pour n'importequelle recherche, qui renvoie un échantillon fixe côté TheMealDB lui-même
(documenté dans le code, mais sans mention "vérifié en direct" contrairement
aux commentaires équivalents de 750g/Manger Bouger — potentiellement
approximatif).
Amélioration proposée : quand
queryest vide, énumérer parcatégorie plutôt que de rester sur
search.php?s=—list.php?c=list(une fois) pour obtenir la liste des catégories, puis
filter.php?c={catégorie}pour chacune, en traitant une catégorie = une"page" du curseur existant (
cursor= index de catégorie dans la liste).Préféré à une énumération par lettre (
search.php?f=a..z, ~26 appels) :filter.phprenvoie déjà exactement la forme dontRecipeSourceListItemabesoin (id/nom/vignette), sans le détail complet que
search.phprenvoieinutilement pour un simple listing (le détail complet reste de toute façon
récupéré séparément via
fetchDetail/lookup.phpau moment de l'import,quelle que soit la richesse de
list()).Point d'attention : la clé API utilisée (
THE_MEAL_DB_API_KEY ?? "1") estdocumentée dans le code comme "une clé de test publique, partagée, gratuite
pour le développement" — dérouler l'énumération une catégorie à la fois (au
rythme du scroll, via le curseur) plutôt qu'en un seul burst reste
raisonnable pour ne pas la solliciter d'un coup.
750g/Marmiton/Manger Bouger : aucun changement de code requis pour cette
demande.
Fichiers à toucher
apps/web/src/features/recipes/sources/SourceItemTable.tsx— pattern"latest ref" ci-dessus.
apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx— mergedédupliqué par
externalId+ arrêt local si une page n'ajoute rien denouveau.
apps/web/cypress/e2e/recipe-sources.feature+apps/web/cypress/support/step_definitions/recipe-sources.ts— nouveauscénario à ≥3 pages avec curseurs distincts effectivement vérifiés.
apps/api/src/sources/the-meal-db.ts—list(): branche "requête vide"→ énumération par catégorie décrite ci-dessus.
apps/api/test/recipe-sources/the-meal-db.test.ts— nouveaux cas pourcette branche (aujourd'hui aucun test n'appelle
list()avec une requêtevide du tout — seule lacune de couverture identifiée côté TheMealDB ;
750g.test.tsa déjà un bloc dédié à ce cas,manger-bouger.test.tsl'exerce indirectement puisque son code ne distingue pas requête vide/non
vide).
Hors scope
infini de l'app — aucune autre identifiée à ce jour,
SourceItemTableestla seule.
ci-dessus — non confirmé comme un vrai bug, à réévaluer si ça se produit
réellement.
🤖 Filed by Claude Code