this was the most stubborn bug ive ever faced in mayinab.
the symptom: when navigating fast between subjects and subtopics, the app froze permanently. wasnt lag. was a full freeze. had to kill the process and reopen. (ꐦ°᷄д°᷅)
and the bug was intermitent. worked fine for a while, then out of nowhere it froze. impossible to reproduce consistently, unless u did a stress test with rapid clicks.
the 6 tries that failed
i spent DAYS thinking it was GptMarkdown / flutter_math_fork (bc they had given me headaches before with the LaTeX bug). i tried everything:
| # | what i tried | worked? |
|---|---|---|
| 1 | replace GptMarkdown with AppMarkdown in flashcards |
|
| 2 | remove IntrinsicWidth from flashcards and checklists |
|
| 3 | remove IntrinsicWidth from ALL note types |
|
| 4 | debounce on ChatScrollObserver callback |
|
| 5 | RepaintBoundary on every note and every markdown |
|
| 6 | remove toRebuildScrollViewCallback completely |
try 4 was the most frustrating. the debounce just slowed the loop to ~30Hz instead of killing it. every 32ms a setState ran, the list rebuilt with slightly different sizes, the observer flipped again... infinite loop but slower. still infinite tho. ¯\_(ツ)_/¯
the root cause: ChatScrollObserver
after 6 fails i finally stopped blaming markdown and went to read the source code of ChatScrollObserver from the scrollview_observer package. and there was the monster.
three interlinked mechanisms caused the freeze:
1. perpetual chain of addPostFrameCallback
the ChatScrollObserver constructor schedules observeSwitchShrinkWrap(). that method, internally, schedules another addPostFrameCallback. reattach() calls innerReattachCallBack which fires _setupSliverController(), which schedules yet another callback. result: infinite callback loop that never stops. its a runaway train.
// the constructor already starts scheduling chaos
ChatScrollObserver(this.observerController) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
observeSwitchShrinkWrap(); // ← schedules ANOTHER callback internally
});
}
2. standby() forces re-layout without position correction
standby() calls viewport.markNeedsLayout() to force re-layout, but without ChatObserverClampingScrollPhysics (which i never configured on the ListView), position correction is never executed. markNeedsLayout() just causes unnecessary work that feeds the observation loop.
3. shrinkWrap mismatch
ChatScrollObserver starts with innerIsShrinkWrap = true, but my ListView.builder had shrinkWrap: false hardcoded. observeSwitchShrinkWrap detects the difference, flips to false, calls reattach(), which schedules new callbacks... and the cycle repeats forever. ヽ(°〇°)ノ
the fix (try 7)
i removed ChatScrollObserver entirely. no half measures.
// BEFORE (buggy):
_chatScrollObserver = ChatScrollObserver(_observerController)
..fixedPositionOffset = 5;
// AFTER (working):
// nothing. removed. bye bye.
i kept the pure ListObserverController + ListViewObserver (bc those are used for jumpTo, animateTo and pagination via onObserve). they dont have the crazy side effects of the chat wrapper.
bonus: bugs found along the way
while investigating the freeze, i found 3 more hidden bugs that were contributing to the chaos:
it was created inline inside build() on every rebuild, never dispose()d. each rebuild = new orphan FocusNode. moved to a class field with proper dispose().
_watchChanges() in PaginatedNotesNotifier created a new subscription without canceling the previous one when parameters changed. added _changeSubscription?.cancel() before creating a new one.
_refreshLoaded() replaced the entire state with a new list even when the content was identical (same IDs, same updatedAt). added deep equality check to skip when nothing actually changed.
results
| metric | before | after |
|---|---|---|
| intermitent freeze | yes | no |
dart analyze lib/ |
0 errors | 0 errors |
flutter test |
— | 720 passed |
| tries | 6 fails | 1 success |
lesson learned
read the source code of the packages u use. i lost days thinking it was GptMarkdown, IntrinsicWidth, the layout... when the problem was a scroll package i had never opened the source of. if i had read the ChatScrollObserver constructor on day 1, i would have saved 6 tries.
and be suspicious of anything that schedules addPostFrameCallback inside another addPostFrameCallback. thats a recipe for infinite loop. (⌐■_■)