fix: the library viewport check survives a position without dimensions #176
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/library-viewport-check-survives-a-position-without-dimensions"
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?
What's in
/library/<id>— what the assistant's navigate tool does — no longer crashes the library withNull check operator used on a null value(#160). The post-frame viewport-fill check now asksScrollPosition.hasContentDimensionsinstead of trustingScrollController.hasClients, which only proves a position is attached, not that layout has filled its extents._RenderTheater.performLayoutonly lays out the onstage children, so anOverlaynever lays out a route covered by an opaque one. The page stack go_router builds for that deep link puts a freshLibraryPageunder the opaque detail route, so its grid attaches aScrollPositionin a frame that never lays it out._fillCheckScheduled, set when scheduling and cleared on_maybeFillViewport's first line, before every guard). Without it each queued check re-armed itself and every connected-record rebuild added another, so the number draining per frame grew with each rebuild the page survived while covered.app/test/library_page_test.dart, one of them counting runs throughLibraryPage.debugFillCheckRuns, a@visibleForTestingtally bumped behindkDebugModeand compiled out of release builds.app/lib/presentation/pages/library/library_page.dartandapp/test/library_page_test.dart.Verification
scripts/verify.sh appwas run on the pushed head1b209d5and is green: submodule check,flutter pub get,build_runner clean+buildwith the generated-file freshness diff,flutter analyze,flutter test—All tests passed!, 1321 tests,== verify OK (app) ==. The backend half was not run; no backend file is touched.Layers exercised: the UI layer only, through widget tests that use the real
Navigator/Overlay, the realScrollControllerand the real post-frame callback queue against the real reducers. No REST, use-case or persistence code is involved in this change, and none was exercised. Nothing was run in the real app against a live server or on the reporting device: #160 is reproduced and fixed in the widget-test harness, not on the owner's machine.Mutation probes. Each was applied to the committed code, the whole file suite (40 tests) was run, and the probe was reverted with
git checkout -- <file>withgit status --porcelainverified empty in between. Probes 1–5 were re-run independently at1b209d5by a second pass; 6 and 7 were run when the de-duplication commit was written.hasContentDimensionsguard deleted → red, 3 failures; "the fill check survives a grid that has not laid out yet" fails with issue #160's own_TypeError: Null check operator used on a null valueatScrollPosition.maxScrollExtent←_maybeFillViewport.return→ red, 1 failure, "a grid that lays out late is still filled". The counting test correctly stays green here: with a bare return each rebuild still queues exactly one check, so only the late-layout test can tell skip from re-arm.if (_fillCheckScheduled) return;deleted → red, 1 failure, "only one fill check is queued while the grid stays unlaid",Expected: <1> Actual: <2>. A count of 2 needs both the re-armed check and the rebuild-scheduled check to run in the same frame, so the fixture does reach the branch it claims to pin._fillCheckScheduled = false;deleted from the top of_maybeFillViewport(the flag latches on forever) → red, 6 failures: the counting test plus all four pre-existing fill tests and the late-layout test.!mounted || !hasClientsguard — the realistic refactoring mistake, since a check draining on a spinner frame would then latch the flag forever → red, 4 failures.if (lib.error != null) return;deleted → red, 1 failure, "stops filling after a failed load-more instead of retrying"; the pre-existing guard survived this change intact.maxScrollExtent > 0weakened to>= 0, so the check never fills → red, 5 failures.No probe survived. Collateral checks:
app/test/offline_page_test.dart, the only other suite that buildsLibraryPage, green at 13 tests;flutter analyzeon both changed files reportsNo issues found!, which matters here because the repo treats analyzer infos as fatal; the new counting test also passes standalone under--plain-name, so it does not depend on the static tally left by earlier tests in the file.Confirmed by reading only, not by a test: the SDK claims the fix rests on —
hasClientsis_positions.isNotEmpty,maxScrollExtentis_maxScrollExtent!,hasContentDimensionsis set byapplyContentDimensionsduring layout, andaddPostFrameCallbackadds to_postFrameCallbackswithout scheduling a frame of its own — were read in the installed Flutter 3.47.3 source. Also by reading only:_onScrollreadsmaxScrollExtenttoo but is driven by a scroll activity that cannot exist before the viewport has laid out, so it was left alone; that is an observation, not a guarantee, and it is out of scope here. Finally, the de-duplication has no externally observable effect by construction — duplicate checks drain in the same frame after the same layout against the same state, and the reducer setsisLoadingMoresynchronously so they cannot double-fetch — which is why it is pinned through the debug-only counter rather than through a dispatch or render assertion.Follow-ups
None. Nothing was deferred, so
docs/BACKLOG.mdis unchanged.Closes #160
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A crash fix where the fix is smaller than the explanation of why it's correct — and the explanation is right!
hasClientsproves a position is attached,maxScrollExtentis_maxScrollExtent!under the hood, and an Overlay that never lays out a covered route is exactly the trap a/library/<id>deep-link springs. You didn't just patch the crash, you pinned the whole mechanism down and then made sure the patch can't silently un-happen. I got goosebumps~ ♡Verdict: ✅ Looks good to me~
Independently verified at
1b209d5(CI absent for this head — no bot comment yet — so local checks it is):_scheduleViewportFillCheck/_maybeFillViewport+ the debug tally. Merge base == base (b13f4d5), no drift.addPostFrameCallbackschedules no frame of its own; no CPU spin while covered._fillCheckScheduled = falseon the first line of_maybeFillViewport, before every guard — this is the load-bearing line. A check draining on a spinner frame (isLoadingearly-return) or an error frame must re-arm the queue, and it does. The counting test would catch anyone "tidying" it lower.hasContentDimensionsguard deleted → exactly "the fill check survives a grid that has not laid out yet" red; (2) re-arm → barereturn→ exactly "a grid that lays out late is still filled" red (counting test correctly stays green — beautiful probe separation, you predicted that too); (3) dedup early-return deleted → counting test redExpected: <1> Actual: <2>; (4) clear moved below the!mounted || !hasClientsguard → counting test + fill tests red. 4/4 red on exactly the branch each test claims to pin. Not a tautology in the batch~flutter analyzeNo issues found;library_page_test40/40 + collateraloffline_page_test13/13; full suite 1321/1321 passed — matches your verify.sh claim digit for digit. Counting test also green standalone under--plain-name.DoujinsLoadedActionlands with matchingrequestSeqand stampslastLoadedAt, solistDoujinsCalls == 0in the late-layout test genuinely proves no epic fetch happened — the assertion has teeth.@visibleForTestingstatic behindkDebugModefollows thevariant_tabs_panel.dartsibling seam, the doc comment justifies why counting is the only honest observable (duplicate checks are behaviorally identical by construction — true, the reducer setsisLoadingMoresynchronously), and delta-based reads make the never-reset static bleed-proof across tests.💡 Little ideas (non-blocking)~
_onScrollreadspos.maxScrollExtentwith onlyhasClients-by-implication safety. Your PR body's own reasoning (a scroll activity can't exist before layout) holds today, but the repo already has a house style of deliberate defensive mirrors with a comment saying exactly that (cf. the reader's min-KB mirror of the settings parse filter). Aif (!pos.hasContentDimensions) return;twin there would make the invariant local instead of a global SDK argument. Pure nicety — the code is correct as written.✅ What I liked~
hasContentDimensions == falsewhile covered, so the test can't silently stop reproducing #160 if the harness changes. That's how you keep a regression test honest~error != null,hasMorePages) carried intact, and your probe 6 confirms they still have teeth.Merge it before I change my mind~ ♡
Automated review by Jibril · 2026-09-20
CI/CD: absent for head SHA
1b209d5· Local checks: analyze clean · 1321/1321 tests · 4/4 independent mutation probes red