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
2 changed files with 44 additions and 27 deletions
Showing only changes of commit c842fbb348 - Show all commits

View file

@ -63,9 +63,11 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns three pages, each addressed by its own cursor And browsing TheMealDB returns three pages, each addressed by its own cursor
When I visit "/recettes" When I visit "/recettes"
And I click the button "TheMealDB" And I click the button "TheMealDB"
Then I should see the source item "Page One Dish" Then I should see the source item "Page 1 Item 1"
And I should see the source item "Page Two Dish" When I scroll the source list to the bottom
And I should see the source item "Page Three Dish" Then I should see the source item "Page 2 Item 1"
When I scroll the source list to the bottom
Then I should see the source item "Page 3 Item 1"
And no browse cursor should have been requested more than once And no browse cursor should have been requested more than once
Scenario: Offers a retry when loading the next page fails Scenario: Offers a retry when loading the next page fails

View file

@ -133,28 +133,39 @@ Given("browsing TheMealDB returns two pages of items", () => {
}); });
/** /**
* Three real pages, each addressed by a distinct `cursor` value the mock * Three full pages, each addressed by a distinct `cursor` value the mock
* actually parses (not just "does the URL contain `cursor=`"). Records * actually parses (not just "does the URL contain `cursor=`"). Pages are
* every cursor it serves so the scenario can assert none was requested * deliberately large enough to overflow the list's own scroll container so
* twice — the tell-tale of the issue #38 loop, where a stale closure * the sentinel starts below the fold — each `scrollTo("bottom")` then
* re-requests the same page forever instead of advancing. * produces a real IntersectionObserver transition, the only thing that
* makes the observer fire again. Records every cursor it serves so the
* scenario can assert none was requested twice — the tell-tale of the
* issue #38 loop, where a stale closure re-requests the same page forever
* instead of advancing.
*/ */
const THREE_PAGE_SIZE = 15;
const threePageCursorsRequested: string[] = []; const threePageCursorsRequested: string[] = [];
function threePagePage(pageNo: number, nextCursor: string | null) {
return {
items: Array.from({ length: THREE_PAGE_SIZE }, (_, index) => ({
externalId: `p${pageNo}-${index}`,
title: `Page ${pageNo} Item ${index + 1}`,
picture: null,
url: `https://www.themealdb.com/meal/p${pageNo}-${index}`,
alreadyImported: false,
recipeId: null,
})),
nextCursor,
};
}
Given("browsing TheMealDB returns three pages, each addressed by its own cursor", () => { Given("browsing TheMealDB returns three pages, each addressed by its own cursor", () => {
threePageCursorsRequested.length = 0; threePageCursorsRequested.length = 0;
const item = (externalId: string, title: string) => ({ const pages: Record<string, ReturnType<typeof threePagePage>> = {
externalId, "": threePagePage(1, "cursor-2"),
title, "cursor-2": threePagePage(2, "cursor-3"),
picture: null, "cursor-3": threePagePage(3, null),
url: `https://www.themealdb.com/meal/${externalId}`,
alreadyImported: false,
recipeId: null,
});
const pages: Record<string, { items: unknown[]; nextCursor: string | null }> = {
"": { items: [item("p1", "Page One Dish")], nextCursor: "cursor-2" },
"cursor-2": { items: [item("p2", "Page Two Dish")], nextCursor: "cursor-3" },
"cursor-3": { items: [item("p3", "Page Three Dish")], nextCursor: null },
}; };
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const cursor = new URL(req.url).searchParams.get("cursor") ?? ""; const cursor = new URL(req.url).searchParams.get("cursor") ?? "";
@ -163,14 +174,18 @@ Given("browsing TheMealDB returns three pages, each addressed by its own cursor"
}); });
}); });
Then("no browse cursor should have been requested more than once", () => { When("I scroll the source list to the bottom", () => {
// Let the panel settle (auto-scroll + one-page-ahead prefetch) before cy.get(".source-items-column .recipe-table-wrap").scrollTo("bottom", {
// asserting. A looping IntersectionObserver would re-hit "" or "cursor-2" ensureScrollable: false,
// here; the latest-ref fix keeps each cursor to a single request.
cy.wait(600).then(() => {
const seen = [...threePageCursorsRequested];
expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size);
}); });
// IntersectionObserver callbacks are delivered on their own task, and the
// next page's fetch + merge then needs a render — give both a beat.
cy.wait(400);
});
Then("no browse cursor should have been requested more than once", () => {
const seen = [...threePageCursorsRequested];
expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size);
}); });
// The panel prefetches the next page as soon as page 1 is on screen (before // The panel prefetches the next page as soon as page 1 is on screen (before