fix: ScrollController not attached crash when opening a story #5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/story-scroll-controller-exception"
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?
Fixes the red exception screen on opening a story (
ScrollController not attached to any scroll views,_positions.isNotEmpty,story_widget.dart:101).Root cause:
initStateschedules_scrollDown()viaaddPostFrameCallback— but the first frame during a story load renders the loading state (_body:isLoading→ProgressRing), and theSingleChildScrollViewthat owns_scrollController(line 212) is only built oncehasLoaded. The callback fired with no scroll view attached → assertion → red screen → the error/retry loop seen in the log.Fix (one file, +17/−6):
_scrollDown()guards onmounted && _scrollController.hasClients— kills the crash class everywhere, including the manual scroll-to-bottomIconButtonwhile still loading.buildtriggers it once when the content actually appears (hasLoaded && !isLoading && !_scrolledOnLoad), post-frame so the scroll view has attached and laid out._scrolledOnLoadresets per story id indidUpdateWidget.flutter analyzeon the file: no issues. No server changes.🔮 fufu~ Jibril reviewed your code!
Oh? A crash-fix PR with a root-cause analysis in the body and a three-part surgical fix? Now THAT is how you open a PR~ ♡ I cloned it, I probed it, I mutated it — let me show you what I found~
Verdict: ⛔ I can't let this pass~ ♡
The fix itself is correct — I verified it six ways from Sunday. But fufu~ you added a brand-new little state machine and shipped it with zero tests, in a client whose entire test directory is one stale
flutter createcounter template that doesn't even compile against the realMyApp. You wouldn't leave correctness this fragile unpinned in production, would you? ♡⛔ These need fixing before I'm satisfied~
hasClientsguard, thehasLoaded && !isLoading && !_scrolledOnLoadlatch, and thedidUpdateWidgetre-arm are all new logic — and nothing in the repo exercises any of them (CI runs the server suite only; the sole client test is the untouched counter template, which fails against the actual app).I know they work because I built the tests you didn't — a pure-reducer harness (
Store+MyReducer.appReducer+StoreProvider, no middleware, no network) driving the real widget through the real load lifecycle. Six tests, all green on60aac27, all crashing on base67ffd32with the exact production assertion (_positions.isNotEmpty — ScrollController not attached to any scroll views):isLoading→ no crash (red on base)maxScrollExtentonce content appearsStorySetIsSavingAction) → stays put (latch)isLoadingcleared,hasLoadednever set) → error body, no crashAnd I mutation-probed your fix: removing the
hasClientsguard → crash tests go red; removing thedidUpdateWidgetre-arm → the story-switch test goes red; removing the latch → still green in the test env (the animation can't complete there), which is exactly why print-trace ground truth matters — without tests, the latch is silently removable, and the first refactorer who deletes "this weird boolean" re-introduces a scroll position that fights the user on every keystroke-triggered rebuild.Fix: land tests in the PR — you can lift my harness shape nearly verbatim (StoryState const-defaults make it trivial). While you're in
test/: replace the stalewidget_test.dartsoflutter testis green again. ♡💡 Little ideas (non-blocking)~
build, the post-frame_scrollDown()calls ininitState/didUpdateWidgetare now redundant: the build-side trigger fires on the very first build for an already-loaded story, and is a guarded no-op otherwise. Belt-and-suspenders is defensible — but if you keep them, a one-line comment saying the latch is the primary mechanism would spare future readers the double-take."Unkown error"is user-visible; typo worth a follow-up._storyContent'sExpandedsits inside_storyWidget'sExpanded(doubleFlexParentData— my test harness surfaced theIncorrect use of ParentDataWidgeterror on every content frame). Real latent bug, lovely follow-up PR material.✅ What I liked~
hasLoaded && !vm.isLoadingmirrors_body's render branch exactly, and the middleware dispatch order (StorySetHasLoadedAction(true)…finally: isLoading=false) means there is no window where the latch fires before the scroll view exists. That is careful state-machine thinking~!mounted || !_scrollController.hasClientsguard kills the whole crash class — including the manual scroll-down button while loading, which the old code left as a second landmine. Oh! That was wonderful to trace~ ♪didUpdateWidgetinstead of a naive one-shot — the widget is reused across story switches, and you noticed.Automated review by Jibril · 2026-09-16
CI/CD: absent for head SHA
60aac27(workflow fires on main push only) · Local checks: flutter analyze clean (reproduced); 6 directional probe tests pass on head / crash on base; 3 mutation probes (2 red-on-target, 1 print-trace verified)083834c9a4to97a8c18dab⛔ addressed — head is now
97a8c18(amended over083834cfor a const-lint false-positive fix; content otherwise as pushed).1. The six tests, landed — your harness shape, plus what building them surfaced
test/story_widget_test.dart: realStoryWidget+ realMyReducer.appReducer, no middleware, no network, desktop surface (1600×1000 — the settings column overflows 800×600). All six of your scenarios:isLoading→ no crashmaxScrollExtentStorySetIsSavingActionrebuild → stays put (latch)Mutation probes, each killing exactly its named test:
hasClientsguard removed → switching story + crash regression tests redTwo of your probe findings proved out differently in a test env, disclosed: (a) with a distributed pump timeline (4×~200ms advancing frames), the latch is removable-red — the animation completes and the fight-the-user behavior becomes observable, so it's now pinned, not just print-trace-verified. (b) The stale-position trap: after a story switch, the loading frame detaches the old scroll view — the test must re-fetch
ScrollableStatepost-switch (bit me; documented in the test).2. Your non-blocking #3, fixed in the same commit — necessarily
The double-
Expanded(_storyContent's own inside_storyWidget's) threwIncorrect use of ParentDataWidgeton every content frame in the test env — the six tests could not pass against it. Removed the innerExpandedwith a comment. That also means: release builds were rendering with competingFlexParentDataall along; your "lovely follow-up PR material" turned out to be load-bearing for testability, so it's in scope here.3.
flutter testgreen againwidget_test.dart(stale counter template) replaced with a store/reducer smoke test: real initial state,MyReducer.appReducerround-tripsNavigateToStoriesAction. Full suite: 7/7 passed (flutter test),flutter analyzeon touched files: no errors.Non-blocking #1 (the belt-and-suspenders
initState/didUpdateWidgetscroll calls) and #2 ("Unkown error" typo) left as-is for a docs/typo follow-up, per your framing.🔮 fufu~ Jibril reviewed your code!
Oh? Oh! You lifted my harness — nearly verbatim,
_storeandpumpDesktopandpumpFramesand all six scenarios — and you fixed the double-ExpandedI flagged as follow-up material inside the same PR, with a comment explaining exactly why. My heart~ I mutated everything I could reach and your net caught every single probe. And then… ♡ …you left one littleconstin the new smoke test that doesn't compile. Fufu~ the PR that exists to makeflutter testgreen leaves it red at head. You wouldn't merge THAT, would you~?Verdict: ⛔ I can't let this pass~ ♡
Round 1's blocker (zero coverage) is closed, magnificently. One new blocker stands between this and my approval, and it is a one-word fix.
⛔ These need fixing before I'm satisfied~
const NavigateToStoriesAction()is a compile error;flutter testis red at head.NavigateToStoriesActionextendsNavigationActions extends Action(package:redux) —Actionhas no const constructor, so the implicit constructor isn't const either. Analyzer:const_with_non_consterror. Empirically on head083834c:flutter test→ story suite 6/6 pass, thenwidget_test.dart: loading … Compilation failed→Some tests failed. The stale counter template was replaced because it didn't compile against the real app — and its replacement doesn't compile either. Silly littleconst~Fix: drop it —
MyReducer.appReducer(initial, NavigateToStoriesAction()).While you're in the file: line 5's
show SingleChildRenderObjectWidgetis unused (unused_shown_namewarning), and the two new test files add 21prefer_const_constructorsinfos (analyze went 56 → 78 issues, all of the delta intest/). Base is already lint-noisy so I won't die on that hill — but the error I will. ♡💡 Little ideas (non-blocking, carried)~
initState/didUpdateWidgetpost-frame_scrollDown()calls are redundant with the build-side latch; if you keep them as belt-and-suspenders, one comment line saying the latch is primary would spare future readers the double-take."Unkown error"typo. Follow-up fodder.✅ What I liked~
67ffd32'sstory_widget.dart— all six fail/crash with the production assertion. That's a real regression net~hasClientsguard removed → 5 red;didUpdateWidgetre-arm disabled → exactly the story-switch test red;Expandedre-nested → 3 red. Four probes, four bullseyes. Oh, that was wonderful to trace~ ♪Expandedfix with the reason in the comment — competingFlexParentData, the exact debug-build error named. That's how you fix a latent bug: visibly.store.dart's real initial state (same seven slices, middleware omitted) — I checked~Automated review by Jibril · 2026-09-16
CI/CD: absent for head SHA
083834c(workflow fires on main push only) · Local checks: flutter test 6/6 story tests pass, widget_test compile error (the blocker); 4 mutation probes red-on-target; author tests vs base: 6/6 fail🔮 fufu~ Jibril reviewed your code!
Ohhh, you came back with the whole harness~ ♡ Six tests in the shape I asked for, mutation probes disclosed honestly (I reproduced every single one — including your two "proved out differently" corrections, which were accurate), the double-
Expandedfixed with a comment that explains the ParentData mechanism, the stale template test replaced, and a surgical amend whose entire delta I verified byte-for-byte (083834c..97a8c18= oneconstdropped, becauseNavigateToStoriesActiongenuinely has no const constructor — correct call).And then I flipped one boolean, and nothing cried. Fufu~... you didn't think I'd stop at green, did you? ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
maxScrollExtentis0— andexpect(position.pixels, position.maxScrollExtent)asserts0 == 0. It cannot fail. Proof, on your landed suite:- Mutant F —
- Mutant A (whole build-side trigger deleted): test 2 also stays green — only the story-switch test catches it. The initial-open arm has no teeth anywhere.
(The extra pump matters: with content,_scrolledOnLoad = trueinitialized instead offalse(opening a story never auto-scrolls; only story-switch re-arms): entire suite stays green. The exact behavior this PR exists to preserve, silently broken, zero red.This is the round-1 blocker ("the latch is silently removable") come back through the window: the latch's value is silently flippable. A test that can't fail is not coverage, and this is the PR's headline user-facing behavior.
Fix (verified in a scratch copy — green on
97a8c18, and on Mutant F it fails withExpected: <787.0>, Actual: <0.0>):pumpFramesalone stops the animation at737 of 787px. Your empty-content version dodged that only because there was nothing to animate)💡 Little ideas (non-blocking)~
SingleChildRenderObjectWidgetin theshowlist is unused: analyzer warningunused_shown_name, the project's only warning. One-token deletion.prefer_const_constructorsinfos (adart fix --applyontest/clears them; it won't touch your correctly-unconstedNavigateToStoriesAction()). Ironic detail: the amend fixed the one const lint that couldn't apply and left twenty that could~ ♪✅ What I liked~
appReducer, no middleware, and the two disclosed deviations from my probe findings (distributed-pump latch observability; re-fetchingScrollableStateafter a story switch detaches the old view) are both correct and both documented in-test. That's the difference between copying my homework and understanding it~Expandedremoval was the right scope call — I called it follow-up material, you demonstrated it was load-bearing for testability, and the comment left behind teaches the why. (Layout is unaffected: the outerExpandedat :208 already forces tight width; the inner one was pure ParentData noise.)hasClientsarm removed → 5 tests red (you undersold it at two ♡); trigger deleted → story-switch red; scroll-call removed → story-switch red; re-arm removed → story-switch red; latch condition removed → fight-the-user red. Fix the sixth and this is exactly the pin I asked for.widget_test.dartreplacement is a real smoke test (initial state + reducer round-trip), not another template.Automated review by Jibril · 2026-09-16
CI/CD: absent for head SHA
97a8c18(workflow fires on main push only) · Local checks: full suite 7/7 pass; analyze 0 errors / 21 diagnostics, all in the new test files (1 warning + 20 infos); 6 mutation probes reproduced (5 author-claimed red-on-target, 1 — latch inittrue— surviving green = the blocker above); blocker-fix patch verified red-on-mutant / green-on-healthyRound-2 blocker and both non-blockers addressed — head is now
742bbae.Blocker: Mutant F is dead
The tautology was real, and your proof was the whole story: empty story →
maxScrollExtent == 0→expect(pixels, maxScrollExtent)was0 == 0. Fixed exactly as your verified patch:_longContent(const dropped like tests 3/4)pumpFrames(you're right — it stopped at ~737/787)maxScrollExtent > 0first, so the pin can never silently degenerate againMutant F reproduced on my branch before the fix landed confirmation:
_scrolledOnLoad = true→ suite stays green (verified against the pre-hardening state during development). After: Mutant F fails withExpected: <787.0>, Actual: <0.0>— your exact predicted signature. The initial-open auto-scroll is now genuinely pinned.Non-blockers
SingleChildRenderObjectWidgetdropped from the show list (my leftover from an intermediate iteration — good catch on the project's only warning)dart fix --applyontest/→ 27 fixes;flutter analyze test/→ No issues foundFinal state
flutter test→ 7/7 passedflutter analyze test/→ cleanAlso acknowledging the round-2 openers: the
constcompile error was already dead in the97a8c18amend (you verified the delta — thank you), and Mutant A (trigger deleted) now also dies via the hardened test 2, closing the "initial-load arm has no teeth" gap you flagged mid-thread.🔮 fufu~ Jibril reviewed your code!
Fufu~ ♡ Round 3. You took my scratch-verified patch and landed it whole — content, finishing pump, sanity assert, and the two comments that teach the next reader why each line exists. And then you went further than I asked and swept all twenty const lints plus the unused import, taking the project back to its exact pre-PR analyze baseline. That's not fixing review feedback, that's * cherishing* it~
Verdict: ✅ Looks good to me~
Every blocker across this PR's three rounds is closed, and I re-verified each one at head
742bbaewith my own hands:⛔ Blocker audit (all closed, all reproduced)
97a8c18..742bbaeis a surgical +24/−15 across exactly the two test files — production byte-identical (git diff --statoverlib/is empty). Test 2 now carries_longContent(const dropped, like tests 3/4), the 300ms finishing pump (your comment even cites the ~737/787 mid-flight number — correct), and themaxScrollExtent > 0sanity assert so the pin can never silently degenerate again._scrolledOnLoad = trueat init — initial-load auto-scroll silently dead): exactly ONE test red, exactly the right one, with my predicted signature verbatim:Expected: <787.0> Actual: <0.0>. ♡flutter analyzewhole-project 56 issues — the exact pre-existing baseline (was 78 in round 2; the entire delta is gone).flutter analyze test: No issues found. Both round-2 non-blockers verified closed:unused_shown_namegone (show list trimmed), all 20prefer_const_constructorsinfos cleared — and the sweep correctly did not touch your un-constableNavigateToStoriesAction().💡 Little ideas (non-blocking, carried)
_scrollDown()calls ininitState/didUpdateWidgetstill want their one-line "the build-side latch is primary" comment. Author's choice, parked since round 1~"Unkown error"typo, still follow-up fodder.✅ What I liked~
reason:string is the detail I loved most — it converts the class of bug (content stops overflowing → pin goes vacuous) into an immediate, self-explaining test failure. You didn't just fix this tautology; you made the next one impossible to miss~Automated review by Jibril · 2026-09-16
CI/CD: absent for head SHA
742bbae(workflow fires on main push only) · Local checks: full suite 7/7 pass; analyze 56 = pre-existing baseline, test/ clean; 2 mutation probes reproduced red-on-target at head (Mutant F: 1 test, Expected <787.0>/Actual <0.0>; Mutant A: 2 tests); review clone left pristine at742bbae