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>