fix(recipes): stoppe le scroll infini des sources au lieu de boucler #46

Merged
kyuno merged 3 commits from fix/source-scroll-loop into main 2026-09-05 21:00:23 +02:00
Owner

Corrige l'issue #38 — deux volets.

1. Le bug de bouclage

L'IntersectionObserver de SourceItemTable.tsx a [hasMore] comme seule dépendance et ne se réabonne donc jamais pendant un scroll qui progresse normalement (hasMore reste true). Il capturait pour toujours la première closure onLoadMore (= handleLoadMore, redéclarée à chaque rendu, qui fige browseState.nextCursor par valeur). Chaque intersection suivante rejouait la même closure figée → même curseur périmé redemandé en boucle → merge sans déduplication → la liste s'allonge indéfiniment avec les mêmes éléments, nextCursor n'atteint jamais son vrai null, la sentinelle reste montée, l'observer continue de se déclencher.

Fix :

  • SourceItemTable.tsx — pattern « latest ref » : onLoadMore est lu via une useRef réassignée à chaque rendu. L'observer appelle toujours la closure la plus fraîche, sans réabonnement ([hasMore] inchangé — l'intention du biome-ignore, désormais inutile, est conservée).
  • RecipeSourcesPanel.tsx — le merge de handleLoadMore déduplique par externalId et traite « cette page n'a rien ajouté de nouveau » comme une fin de liste locale, même si la source renvoie encore un nextCursor non nul. Garde-fou complémentaire (heuristique de fin imparfaite d'un adaptateur).

2. Maximiser les recettes en recherche vide (TheMealDB)

theMealDbAdapter.list() tapait /search.php?s= même sans query — TheMealDB borne ça à un échantillon fixe (~25 recettes), nextCursor codé en dur à null. Le scroll infini n'avait donc rien à charger.

Une query vide/absente déroule maintenant la liste des catégories : /list.php?c=list une fois, puis /filter.php?c={catégorie} une catégorie à la fois, le cursor étant l'index de catégorie (une catégorie = une « page »). La liste des catégories est re-récupérée à chaque pas plutôt que mise en cache — une petite requête, l'adaptateur reste sans état, et c'est de toute façon cadencé au rythme du scroll. 750g / Marmiton / Manger Bouger : aucun changement (déjà corrects).

Tests

  • apps/web/cypress/e2e/recipe-sources.feature + .ts — nouveau scénario à 3 pages réelles avec curseurs distincts effectivement vérifiés ; assertion « aucun curseur redemandé plus d'une fois » (échoue avec l'ancien code, où le curseur périmé est redemandé en boucle).
  • apps/api/test/recipe-sources/the-meal-db.test.ts — marche par catégories : première page + curseur suivant, suivi du curseur, arrêt (nextCursor: null) sur la dernière catégorie, page finale vide au-delà, requête blanche traitée comme requête vide, saut d'un repas sans titre.
  • specs/backend-architecture.md — paragraphe TheMealDB mis à jour.

🤖 Generated with Claude Code

Corrige l'[issue #38](https://git.dev.kyuno.fr/kyuno/batchCooking/issues/38) — deux volets. ## 1. Le bug de bouclage L'`IntersectionObserver` de `SourceItemTable.tsx` a `[hasMore]` comme seule dépendance et ne se réabonne donc jamais pendant un scroll qui progresse normalement (`hasMore` reste `true`). Il capturait **pour toujours** la première closure `onLoadMore` (= `handleLoadMore`, redéclarée à chaque rendu, qui fige `browseState.nextCursor` par valeur). Chaque intersection suivante rejouait la même closure figée → **même curseur périmé redemandé en boucle** → merge sans déduplication → la liste s'allonge indéfiniment avec les mêmes éléments, `nextCursor` n'atteint jamais son vrai `null`, la sentinelle reste montée, l'observer continue de se déclencher. **Fix :** - `SourceItemTable.tsx` — pattern « latest ref » : `onLoadMore` est lu via une `useRef` réassignée à chaque rendu. L'observer appelle toujours la closure la plus fraîche, sans réabonnement (`[hasMore]` inchangé — l'intention du `biome-ignore`, désormais inutile, est conservée). - `RecipeSourcesPanel.tsx` — le merge de `handleLoadMore` déduplique par `externalId` et traite « cette page n'a rien ajouté de nouveau » comme une fin de liste locale, même si la source renvoie encore un `nextCursor` non nul. Garde-fou complémentaire (heuristique de fin imparfaite d'un adaptateur). ## 2. Maximiser les recettes en recherche vide (TheMealDB) `theMealDbAdapter.list()` tapait `/search.php?s=` même sans `query` — TheMealDB borne ça à un échantillon fixe (~25 recettes), `nextCursor` codé en dur à `null`. Le scroll infini n'avait donc rien à charger. Une `query` vide/absente **déroule maintenant la liste des catégories** : `/list.php?c=list` une fois, puis `/filter.php?c={catégorie}` une catégorie à la fois, le `cursor` étant l'index de catégorie (une catégorie = une « page »). La liste des catégories est re-récupérée à chaque pas plutôt que mise en cache — une petite requête, l'adaptateur reste sans état, et c'est de toute façon cadencé au rythme du scroll. 750g / Marmiton / Manger Bouger : aucun changement (déjà corrects). ## Tests - `apps/web/cypress/e2e/recipe-sources.feature` + `.ts` — nouveau scénario à **3 pages réelles avec curseurs distincts effectivement vérifiés** ; assertion « aucun curseur redemandé plus d'une fois » (échoue avec l'ancien code, où le curseur périmé est redemandé en boucle). - `apps/api/test/recipe-sources/the-meal-db.test.ts` — marche par catégories : première page + curseur suivant, suivi du curseur, arrêt (`nextCursor: null`) sur la dernière catégorie, page finale vide au-delà, requête blanche traitée comme requête vide, saut d'un repas sans titre. - `specs/backend-architecture.md` — paragraphe TheMealDB mis à jour. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
kyuno added 1 commit 2026-09-05 20:21:09 +02:00
fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
c48625cb0c
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kyuno added 1 commit 2026-09-05 20:32:55 +02:00
test(recipes): pilote le scroll dans le scénario 3 pages des sources
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m39s
CI / intent-service-test (push) Successful in 4m18s
CI / e2e (push) Failing after 7m20s
CI / test (push) Has been cancelled
c842fbb348
Le scénario ajouté supposait que l'IntersectionObserver se redéclenchait
tout seul avec une liste courte — faux : il ne se déclenche qu'à une
*transition* d'intersection. Les pages font maintenant 15 éléments (la
liste déborde son conteneur, la sentinelle démarre sous la ligne de
flottaison) et chaque page suivante est demandée via un
`scrollTo("bottom")` explicite, qui produit une vraie transition.

Avec l'ancien code bugué, la sentinelle stale rappelle « cursor-2 » au 2e
scroll : « Page 3 Item 1 » n'apparaît jamais et un curseur est redemandé
— les deux assertions échouent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kyuno added 1 commit 2026-09-05 20:46:04 +02:00
test(recipes): fiabilise le scénario 3 pages (scroll réel + log de curseurs)
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build (push) Successful in 2m12s
CI / intent-service-test (push) Successful in 4m21s
CI / e2e (push) Successful in 6m11s
CI / test (push) Successful in 14m4s
1cd886cb8a
Le scénario échouait en CI sur deux points de fragilité, pas sur le fond :

1. `IntersectionObserver` ne se redéclenche qu'à une *transition*
   d'intersection — avec des pages courtes la sentinelle restait visible en
   continu et seule la 2e page se chargeait. Les pages font maintenant 15
   éléments (la liste déborde son conteneur) et chaque page suivante est
   tirée par un `scrollTo("bottom")` explicite, répété 5 fois.
2. Une fois la liste débordante, `should("be.visible")` sur un élément
   sorti du conteneur `overflow:auto` échoue (même classe de bug que #1) —
   l'assertion "3e page chargée" fait maintenant `.scrollIntoView()` avant.

L'assertion anti-boucle porte sur le journal des curseurs demandés
(tolérance 2× pour le double-effet React StrictMode du serveur e2e), pas
sur un `Set.size` strict.

Vérifié localement : 7/7 avec le correctif, "Page 3 Item 1" jamais chargée
sans lui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kyuno merged commit ec26f32dd8 into main 2026-09-05 21:00:23 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: kyuno/batchCooking#46
No description provided.