#flutter #debug #mobile #scrollview

mayinab froze randomly when navigating subtopics (7 tries to find it)

Miuna

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:

FOCUSNODE LEAK

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().

STREAM SUBSCRIPTION LEAK

_watchChanges() in PaginatedNotesNotifier created a new subscription without canceling the previous one when parameters changed. added _changeSubscription?.cancel() before creating a new one.

REDUNDANT REBUILDS

_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

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. (⌐■_■)

ler em português →