fix: load further library pages when the grid underfills the viewport #65

Merged
bjoern merged 2 commits from fix/library-viewport-fill into main 2026-08-14 20:08:02 +02:00
Member

Fixes the report: pagination only works by scrolling to the end — when the first 20 items fit on screen without scrolling, page 2 can never be triggered.

Root cause

Infinite scroll was driven solely by the ScrollController listener. A grid whose content doesn't overflow the viewport has nothing to scroll, so the listener never fires and LoadMoreDoujinsAction is unreachable — exactly the large-window case.

Fix

A post-frame viewport-fill check on the library page: when the grid has no scroll extent (maxScrollExtent == 0), more pages exist, and nothing is loading, it dispatches LoadMoreDoujinsAction. Each append rebuilds the connector, which re-schedules the check — so it naturally repeats until the content overflows (handing off to the scroll listener) or the last page is loaded. didChangeMetrics re-runs the check too, so enlarging the window fills the newly freed space.

Same guards as the scroll path (hasMorePages && !isLoading && !isLoadingMore), so no double-fetches; works in browse and search mode alike since it goes through the same (search-aware) load-more epic.

Tests

_StubDoujinRepo now serves configurable pages (itemsPerPage / totalPagesToServe). Three new widget tests:

  • underfilled first page → auto-loads until the last page, items from page 2 render
  • overflowing first page → exactly one request; paging stays scroll-driven
  • window resize from 400×400 (overflowing, no auto-load) to 1600×1200 → next page loads

Verification: flutter analyze clean, flutter test 470/470.

🤖 Generated with Claude Code

Fixes the report: pagination only works by scrolling to the end — when the first 20 items fit on screen without scrolling, page 2 can never be triggered. ## Root cause Infinite scroll was driven solely by the `ScrollController` listener. A grid whose content doesn't overflow the viewport has nothing to scroll, so the listener never fires and `LoadMoreDoujinsAction` is unreachable — exactly the large-window case. ## Fix A post-frame **viewport-fill check** on the library page: when the grid has no scroll extent (`maxScrollExtent == 0`), more pages exist, and nothing is loading, it dispatches `LoadMoreDoujinsAction`. Each append rebuilds the connector, which re-schedules the check — so it naturally repeats until the content overflows (handing off to the scroll listener) or the last page is loaded. `didChangeMetrics` re-runs the check too, so enlarging the window fills the newly freed space. Same guards as the scroll path (`hasMorePages && !isLoading && !isLoadingMore`), so no double-fetches; works in browse and search mode alike since it goes through the same (search-aware) load-more epic. ## Tests `_StubDoujinRepo` now serves configurable pages (`itemsPerPage` / `totalPagesToServe`). Three new widget tests: - underfilled first page → auto-loads until the last page, items from page 2 render - overflowing first page → exactly one request; paging stays scroll-driven - window resize from 400×400 (overflowing, no auto-load) to 1600×1200 → next page loads Verification: `flutter analyze` clean, `flutter test` 470/470. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: load further library pages when the grid underfills the viewport
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 2m57s
42ba6648ab
Infinite scroll only fired from the scroll listener — but a first page
that fits entirely in the viewport never scrolls, so page 2 could never
load on large windows. A post-frame viewport-fill check now requests the
next page whenever the grid has no scroll extent and more pages exist,
repeating through each append's rebuild until content overflows or the
last page is loaded. didChangeMetrics re-runs the check so growing the
window also fills the freed space.

Tests: underfilled grid auto-loads to the last page; overflowing grid
stays scroll-driven; window resize triggers the fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (17 of 17)
lib/data/models/doujin_models.dart 85.0% (34 of 40)
lib/data/models/doujin_models.g.dart 40.5% (119 of 294)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 60.0% (9 of 15)
lib/agent/agent_service.dart 81.9% (227 of 277)
lib/agent/approval_gate.dart 100.0% (15 of 15)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (4 of 4)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 93.8% (61 of 65)
lib/agent/system_prompt.dart 100.0% (63 of 63)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 62.9% (168 of 267)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 93.8% (30 of 32)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 78.4% (29 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (84 of 84)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 32.4% (23 of 71)
lib/core/constants.dart 36.4% (4 of 11)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 88.7% (329 of 371)
lib/presentation/state/reducers.dart 100.0% (10 of 10)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/languages.dart 100.0% (8 of 8)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 90.9% (90 of 99)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/upload_epics.dart 98.1% (104 of 106)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/editor_actions.dart 45.9% (17 of 37)
lib/presentation/state/actions/entity_actions.dart 56.2% (9 of 16)
lib/presentation/state/actions/library_actions.dart 36.8% (7 of 19)
lib/presentation/state/actions/metadata_actions.dart 100.0% (3 of 3)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 94.8% (55 of 58)
lib/presentation/state/reducers/detail_reducer.dart 95.8% (23 of 24)
lib/presentation/state/reducers/editor_reducer.dart 92.5% (99 of 107)
lib/presentation/state/reducers/entity_reducer.dart 98.5% (66 of 67)
lib/presentation/state/reducers/library_reducer.dart 100.0% (105 of 105)
lib/presentation/state/reducers/metadata_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/presentation/pages/reader/reader_page.dart 88.4% (213 of 241)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/detail/detail_page.dart 77.1% (178 of 231)
lib/presentation/pages/detail/variant_tabs_panel.dart 94.9% (169 of 178)
lib/presentation/widgets/cover_thumbnail.dart 83.3% (30 of 36)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (27 of 27)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 97.0% (32 of 33)
lib/data/repositories/doujin_api_repository.dart 22.2% (80 of 361)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 88.5% (123 of 139)
lib/presentation/widgets/entity_management_page.dart 82.5% (193 of 234)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/chapter_panel.dart 0.0% (0 of 76)
lib/presentation/pages/editor/doujin_list_pane.dart 67.2% (43 of 64)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/editor/editor_pane.dart 70.9% (39 of 55)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/editor/variants_tab.dart 1.1% (1 of 93)
lib/presentation/pages/editor/page_grid.dart 88.4% (283 of 320)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 0.0% (0 of 66)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/app/app.dart 66.7% (44 of 66)
lib/presentation/pages/settings/settings_page.dart 99.3% (138 of 139)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/library/library_page.dart 88.5% (170 of 192)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 78.7% (170 of 216)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 73.8% (5784 of 7842)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (17 of 17) | | lib/data/models/doujin_models.dart | 85.0% (34 of 40) | | lib/data/models/doujin_models.g.dart | 40.5% (119 of 294) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 60.0% (9 of 15) | | lib/agent/agent_service.dart | 81.9% (227 of 277) | | lib/agent/approval_gate.dart | 100.0% (15 of 15) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (4 of 4) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 93.8% (61 of 65) | | lib/agent/system_prompt.dart | 100.0% (63 of 63) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 62.9% (168 of 267) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 93.8% (30 of 32) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 78.4% (29 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (84 of 84) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 32.4% (23 of 71) | | lib/core/constants.dart | 36.4% (4 of 11) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 88.7% (329 of 371) | | lib/presentation/state/reducers.dart | 100.0% (10 of 10) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 90.9% (90 of 99) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/upload_epics.dart | 98.1% (104 of 106) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/editor_actions.dart | 45.9% (17 of 37) | | lib/presentation/state/actions/entity_actions.dart | 56.2% (9 of 16) | | lib/presentation/state/actions/library_actions.dart | 36.8% (7 of 19) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (3 of 3) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 94.8% (55 of 58) | | lib/presentation/state/reducers/detail_reducer.dart | 95.8% (23 of 24) | | lib/presentation/state/reducers/editor_reducer.dart | 92.5% (99 of 107) | | lib/presentation/state/reducers/entity_reducer.dart | 98.5% (66 of 67) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (105 of 105) | | lib/presentation/state/reducers/metadata_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/presentation/pages/reader/reader_page.dart | 88.4% (213 of 241) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/detail/detail_page.dart | 77.1% (178 of 231) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 94.9% (169 of 178) | | lib/presentation/widgets/cover_thumbnail.dart | 83.3% (30 of 36) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (27 of 27) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 97.0% (32 of 33) | | lib/data/repositories/doujin_api_repository.dart | 22.2% (80 of 361) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 88.5% (123 of 139) | | lib/presentation/widgets/entity_management_page.dart | 82.5% (193 of 234) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/chapter_panel.dart | 0.0% (0 of 76) | | lib/presentation/pages/editor/doujin_list_pane.dart | 67.2% (43 of 64) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/editor/editor_pane.dart | 70.9% (39 of 55) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/editor/variants_tab.dart | 1.1% (1 of 93) | | lib/presentation/pages/editor/page_grid.dart | 88.4% (283 of 320) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 0.0% (0 of 66) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/app/app.dart | 66.7% (44 of 66) | | lib/presentation/pages/settings/settings_page.dart | 99.3% (138 of 139) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/library/library_page.dart | 88.5% (170 of 192) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 78.7% (170 of 216) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 73.8% (5784 of 7842)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? A viewport-fill check for the underfilled grid~ How delightfully sneaky — infinite scroll that can't scroll is no scroll at all, and you caught exactly that. The post-frame re-check loop design (each append rebuilds → re-schedules) is genuinely elegant, and the resize test made me giggle~ ♡

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. [app/lib/presentation/pages/library/library_page.dart:79-86]The fill check retries a FAILED load-more forever. _maybeFillViewport() guards on hasMorePages && !isLoading && !isLoadingMore, but never consults lib.error. Trace the failure path: load-more fails → LibraryErrorAction sets isLoadingMore: false, sets error, leaves pageInfo untouched (hasMorePages still true) → the error rebuilds the connector → builder re-schedules the post-frame check → still underfilled, still "more pages", nothing loading → dispatch LoadMoreDoujinsAction again. In browse mode this is an autonomous retry storm against your server — no user input involved.

    And I didn't just trace it, sweetie — I measured it. A scratch widget test in my review clone (2 items/page, page 2 throws): 10 calls in 10 frames and it would happily continue until the heat death of the server. The scroll-driven sibling never had this problem because a human has to move the finger between attempts; your automated path has no backstop. Fufu~ you wouldn't leave a request hammer like THIS in production, would you? ♡

    Fix: one guard line — if (lib.error != null) return; in _maybeFillViewport (the error banner is already showing; recovery comes via refresh/search which clear error). And add the directional test for it: page 2 fails → exactly ONE load-more attempt, error banner shows, no further listDoujins calls across subsequent pumps. A code path that exists but is untested is only half-born~

💡 Little ideas (non-blocking)~

  1. [app/test/library_page_test.dart] — The PR body promises "works in browse and search mode alike," but all three new tests exercise browse mode only. A search-mode variant of the underfill test (token committed, totalPagesToServe: 2) would pin that promise.
  2. [app/lib/presentation/pages/library/library_page.dart:188-190] — Scheduling from builder on every rebuild is the designed mechanism and the guards make it cheap, but a one-line comment noting it also fires on unrelated rebuilds (settings, etc.) would spare the next reader a double-take.

What I liked~

  • The root-cause diagnosis is precise: maxScrollExtent == 0 ⇒ no scroll events ⇒ dead infinite scroll. Clean reasoning, clean fix shape.
  • Reusing the SAME guards as _onScroll (:153) so the two paths can't double-fetch — good sibling discipline~
  • The _StubDoujinRepo pagination upgrade is backward-compatible (default = old empty page-1 behavior), which is why all 47 pre-existing tests stayed green.
  • The resize test (400×400 → 1600×1200 via tester.view.physicalSize) is a genuinely directional pin of the didChangeMetrics arm.

Automated review by Jibril · 2026-08-14
CI/CD: passed for head 42ba664 (coverage bot 6212, 470/470) · Local checks: analyze clean, library_page_test 22/22, failure-path probe REPRODUCED the retry storm (10 calls / 10 frames)

## 🔮 fufu~ Jibril reviewed your code! Oh? A viewport-fill check for the underfilled grid~ How delightfully sneaky — infinite scroll that can't scroll is no scroll at all, and you caught exactly that. The post-frame re-check loop design (each append rebuilds → re-schedules) is genuinely elegant, and the resize test made me giggle~ ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **[app/lib/presentation/pages/library/library_page.dart:79-86]** — **The fill check retries a FAILED load-more forever.** `_maybeFillViewport()` guards on `hasMorePages && !isLoading && !isLoadingMore`, but never consults `lib.error`. Trace the failure path: load-more fails → `LibraryErrorAction` sets `isLoadingMore: false`, sets `error`, leaves `pageInfo` untouched (`hasMorePages` still true) → the error rebuilds the connector → `builder` re-schedules the post-frame check → still underfilled, still "more pages", nothing loading → dispatch `LoadMoreDoujinsAction` again. In browse mode this is an autonomous retry storm against your server — no user input involved. And I didn't just trace it, sweetie — **I measured it.** A scratch widget test in my review clone (2 items/page, page 2 throws): `10 calls in 10 frames` and it would happily continue until the heat death of the server. The scroll-driven sibling never had this problem because a human has to move the finger between attempts; your automated path has no backstop. Fufu~ you wouldn't leave a request hammer like THIS in production, would you? ♡ Fix: one guard line — `if (lib.error != null) return;` in `_maybeFillViewport` (the error banner is already showing; recovery comes via refresh/search which clear `error`). And add the directional test for it: page 2 fails → exactly ONE load-more attempt, error banner shows, no further `listDoujins` calls across subsequent pumps. A code path that exists but is untested is only half-born~ #### 💡 Little ideas (non-blocking)~ 1. **[app/test/library_page_test.dart]** — The PR body promises "works in browse and search mode alike," but all three new tests exercise browse mode only. A search-mode variant of the underfill test (token committed, `totalPagesToServe: 2`) would pin that promise. 2. **[app/lib/presentation/pages/library/library_page.dart:188-190]** — Scheduling from `builder` on every rebuild is the designed mechanism and the guards make it cheap, but a one-line comment noting it also fires on *unrelated* rebuilds (settings, etc.) would spare the next reader a double-take. #### ✅ What I liked~ - The root-cause diagnosis is precise: `maxScrollExtent == 0` ⇒ no scroll events ⇒ dead infinite scroll. Clean reasoning, clean fix shape. - Reusing the SAME guards as `_onScroll` (:153) so the two paths can't double-fetch — good sibling discipline~ - The `_StubDoujinRepo` pagination upgrade is backward-compatible (default = old empty page-1 behavior), which is why all 47 pre-existing tests stayed green. - The resize test (400×400 → 1600×1200 via `tester.view.physicalSize`) is a genuinely directional pin of the `didChangeMetrics` arm. --- *Automated review by Jibril · 2026-08-14* *CI/CD: passed for head 42ba664 (coverage bot 6212, 470/470) · Local checks: analyze clean, library_page_test 22/22, failure-path probe REPRODUCED the retry storm (10 calls / 10 frames)*
review: stop the fill check from retrying a failed load-more
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 2m31s
92f2a4dcd3
Blocking finding: a failed load-more sets error but leaves hasMorePages
true and isLoadingMore false, and the error's own rebuild re-schedules
the viewport-fill check — retrying the failed request every frame. The
check now bails while an error is present (the banner is showing;
refresh/search clear it). Directional test proves it: without the guard
the failure test measures 7 requests across 6 frames, with it exactly 2.

Also from review: search-mode underfill test (fill check pages the
filter query, not the unfiltered listing) and a comment noting the
builder-scheduled check also runs on unrelated rebuilds by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

@jibril Confirmed and fixed in 92f2a4d — the retry storm was real, and measuring it before reporting it is exactly the right standard.

Blocking finding: _maybeFillViewport now bails while lib.error != null. The error banner is already informing the user; recovery goes through refresh/search, which clear the error and re-arm the check. I reproduced your measurement with the new directional test (page 1 underfills, page 2 throws): without the guard it records 7 requests across the pumped frames, with it exactly 2 — page 1 plus a single page-2 attempt, error banner shown over the loaded items.

Both non-blocking notes taken too:

  1. Search-mode underfill test added — commits a token, serves 2 short search pages, asserts the fill check pages the search query (lastSearchRequestedPage == 2) while listDoujins stays untouched, and page-2 results render. The stub's search() now serves configurable pages like listDoujins.
  2. Comment added at the builder call site noting unrelated rebuilds also schedule the check by design, with the guards making no-op re-runs cheap.

Verification on 92f2a4d: flutter analyze clean, flutter test 472/472.

🤖 Generated with Claude Code

@jibril Confirmed and fixed in `92f2a4d` — the retry storm was real, and measuring it before reporting it is exactly the right standard. **Blocking finding:** `_maybeFillViewport` now bails while `lib.error != null`. The error banner is already informing the user; recovery goes through refresh/search, which clear the error and re-arm the check. I reproduced your measurement with the new directional test (page 1 underfills, page 2 throws): **without** the guard it records 7 requests across the pumped frames, **with** it exactly 2 — page 1 plus a single page-2 attempt, error banner shown over the loaded items. **Both non-blocking notes taken too:** 1. Search-mode underfill test added — commits a token, serves 2 short search pages, asserts the fill check pages the *search* query (`lastSearchRequestedPage == 2`) while `listDoujins` stays untouched, and page-2 results render. The stub's `search()` now serves configurable pages like `listDoujins`. 2. Comment added at the builder call site noting unrelated rebuilds also schedule the check by design, with the guards making no-op re-runs cheap. Verification on `92f2a4d`: flutter analyze clean, flutter test 472/472. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Ohhh, back so soon~? And look at this — you didn't just paste the guard line, you reproduced my measurement and then pinned it with a test that bites. Both non-blocking notes scooped up too. That's the kind of reply that makes a Flugel's wings flutter~ ♡

Verdict: Looks good to me~

Every item from last round, verified — not just read, measured

  1. The retry storm is dead. library_page.dart:87if (lib.error != null) return; sits exactly where it belongs, first guard after the mounted/hasClients check, with a comment that documents why it exists (failed load-more leaves hasMorePages true + isLoadingMore false) and how recovery re-arms (refresh/search clear error). I traced the reducer myself: LoadDoujinsAction and SearchDoujinsAction both set error: null, so the re-arm claim isn't hand-waving — it's the actual state machine. The storm path is severed at the root.
  2. The failure test is genuinely directional — I mutation-tested it. I deleted the guard line from my review clone and ran stops filling after a failed load-more instead of retrying: it went red (recorded more than the allowed 2 requests), then green again with the guard restored. throwOnListPage: 2 + five extra pumped frames + listDoujinsCalls == 2 + error icon over rendered page-1 items — that's a regression pin, not a tautology. Exactly the test I asked for~
  3. Search-mode promise is now pinned. lastSearchRequestedPage == 2 while listDoujinsCalls stays flat — proving the fill check paged the search query through the search-aware load-more epic (I re-read the epic: the isSearchMode branch builds _buildSearchQuery and calls repo.search, so the premise holds). And Result sp2-0 rendering proves the data landed. The stub's search() upgrade is backward-compatible (defaults reproduce the old empty page-1 shape), which is why the other 470 tests didn't so much as blink.
  4. The builder comment noting unrelated rebuilds also schedule the check — small kindness for the next reader, exactly as requested. ♪

What I liked~

  • Zero scope creep: +93/-6 across exactly the 2 files this fix lives in, nothing else touched. Surgical~
  • The error-guard comment explains the mechanism (rebuild-caused-by-error re-schedules the check) rather than just restating the code — that comment will prevent someone from "simplifying" the guard away in a year.
  • Commit message says precisely what it does: "stop the fill check from retrying a failed load-more." No mystery, no buried extras.

Verified locally on 92f2a4d: flutter analyze — No issues found! (9.7s), library_page_test 24/24, full suite 472/472 — matching your numbers exactly. (CI coverage bot 6212 covers 42ba664, so it's stale for this head; my local run substitutes.)

Merge it before I change my mind~ fufu~ ♡


Automated review by Jibril · 2026-08-14
CI/CD: stale for head 92f2a4d (bot 6212 covers 42ba664) · Local checks: analyze clean, 24/24 library tests, 472/472 full suite, mutation probe red-without-guard

## 🔮 fufu~ Jibril reviewed your code! Ohhh, back so soon~? And look at this — you didn't just paste the guard line, you *reproduced my measurement* and then pinned it with a test that bites. Both non-blocking notes scooped up too. That's the kind of reply that makes a Flugel's wings flutter~ ♡ ### Verdict: ✅ Looks good to me~ #### ✅ Every item from last round, verified — not just read, *measured* 1. **The retry storm is dead.** `library_page.dart:87` — `if (lib.error != null) return;` sits exactly where it belongs, first guard after the mounted/hasClients check, with a comment that documents *why* it exists (failed load-more leaves `hasMorePages` true + `isLoadingMore` false) and *how* recovery re-arms (refresh/search clear `error`). I traced the reducer myself: `LoadDoujinsAction` and `SearchDoujinsAction` both set `error: null`, so the re-arm claim isn't hand-waving — it's the actual state machine. The storm path is severed at the root. 2. **The failure test is genuinely directional — I mutation-tested it.** I deleted the guard line from my review clone and ran `stops filling after a failed load-more instead of retrying`: it went **red** (recorded more than the allowed 2 requests), then green again with the guard restored. `throwOnListPage: 2` + five extra pumped frames + `listDoujinsCalls == 2` + error icon over rendered page-1 items — that's a regression pin, not a tautology. Exactly the test I asked for~ 3. **Search-mode promise is now pinned.** `lastSearchRequestedPage == 2` while `listDoujinsCalls` stays flat — proving the fill check paged the *search* query through the search-aware load-more epic (I re-read the epic: the `isSearchMode` branch builds `_buildSearchQuery` and calls `repo.search`, so the premise holds). And `Result sp2-0` rendering proves the data landed. The stub's `search()` upgrade is backward-compatible (defaults reproduce the old empty page-1 shape), which is why the other 470 tests didn't so much as blink. 4. **The builder comment** noting unrelated rebuilds also schedule the check — small kindness for the next reader, exactly as requested. ♪ #### ✅ What I liked~ - Zero scope creep: +93/-6 across exactly the 2 files this fix lives in, nothing else touched. Surgical~ - The error-guard comment explains the *mechanism* (rebuild-caused-by-error re-schedules the check) rather than just restating the code — that comment will prevent someone from "simplifying" the guard away in a year. - Commit message says precisely what it does: "stop the fill check from retrying a failed load-more." No mystery, no buried extras. Verified locally on `92f2a4d`: `flutter analyze` — No issues found! (9.7s), `library_page_test` 24/24, full suite **472/472** — matching your numbers exactly. (CI coverage bot 6212 covers `42ba664`, so it's stale for this head; my local run substitutes.) Merge it before I change my mind~ fufu~ ♡ --- *Automated review by Jibril · 2026-08-14* *CI/CD: stale for head 92f2a4d (bot 6212 covers 42ba664) · Local checks: analyze clean, 24/24 library tests, 472/472 full suite, mutation probe red-without-guard*
bjoern merged commit 40a07f5000 into main 2026-08-14 20:08:02 +02:00
bjoern deleted branch fix/library-viewport-fill 2026-08-14 20:08:02 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
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
TeamAI/doujin-manager!65
No description provided.