fix(search): drop idle placeholder on Explore → Search with prefilled query#4290
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
NotThatKindOfDrLiz
left a comment
There was a problem hiding this comment.
The behavior fix looks right and the added tests cover the edge cases well, but this should be tightened before merge to avoid carrying new maintenance debt into main.
2407333 to
6baff0a
Compare
Address review feedback on PR #4290: - Extract the 4-way *QueryChanged fanout in SearchResultsAppBar into a private `_dispatchQuery` helper, reused by both the synchronous initState path and the debounced _onSearchChanged callback. Removes the pre-existing duplication this PR was about to cement. - Rewrite the synchronous-dispatch comment to drop two inaccurate claims: BLoCs do not leave `initial` this frame (each applies its own internal debounce), and same-query dedup is not generally true (UserSearchBloc explicitly has no such guard). - Trim the long narrative comment in SearchResultsView's idle gate to short intent only; issue-history references belong in the PR body, not in code. No behavior change. All 85 search_results widget tests still pass.
This comment has been minimized.
This comment has been minimized.
6baff0a to
d0ab03b
Compare
Address review feedback on PR #4290: - Extract the 4-way *QueryChanged fanout in SearchResultsAppBar into a private `_dispatchQuery` helper, reused by both the synchronous initState path and the debounced _onSearchChanged callback. Removes the pre-existing duplication this PR was about to cement. - Rewrite the synchronous-dispatch comment to drop two inaccurate claims: BLoCs do not leave `initial` this frame (each applies its own internal debounce), and same-query dedup is not generally true (UserSearchBloc explicitly has no such guard). - Trim the long narrative comment in SearchResultsView's idle gate to short intent only; issue-history references belong in the PR body, not in code. No behavior change. All 85 search_results widget tests still pass.
This comment has been minimized.
This comment has been minimized.
rabble
left a comment
There was a problem hiding this comment.
I ran the focused local check with coverage mode:
flutter test --coverage test/screens/search_results/view/search_results_page_test.dart test/screens/search_results/view/search_results_view_test.dart test/screens/search_results/widgets/search_results_app_bar_test.dart
It passes locally. I’m still requesting changes because the fix couples idle rendering to the immutable route initialQuery, which leaves a real post-navigation state uncovered.
Blocking issue: SearchResultsView decides idle state from initialQuery instead of the current query/BLoC input state:
mobile/lib/screens/search_results/view/search_results_view.dart:25checks whetherVideoSearchBlocis stillinitialmobile/lib/screens/search_results/view/search_results_view.dart:33then combines that withinitialQuery.trim().length < minSearchQueryLength
That handles the initial Explore -> Search case, but after landing on /search-results/hello, if the user clears the field or edits it down to one character, the search BLoCs can go back to initial while initialQuery remains hello. isIdle stays false, so the view falls through to the section skeletons instead of showing the idle placeholder. The current tests cover initial route values, not changing the query after navigation.
Please drive isIdle from the current query/search input state, or otherwise keep SearchResultsView synchronized with the app bar’s current text, and add a regression test for clearing/reducing a prefilled query below minSearchQueryLength after the page has mounted.
… query Tapping into Explore and typing 2+ characters auto-pushes /search-results/:query, but the destination flashed "Enter a search query / Discover something interesting" for ~300-600ms before section skeletons appeared. On slow networks this read as a hang. Hoist the search field's TextEditingController into a stateful _SearchResultsBody so both the app bar (writer) and the view (reader) operate on the same live text. The view drives its idle gate from the live controller value rather than the route arg, so the gate also returns to the placeholder when the user clears or shortens the field below minSearchQueryLength after landing on a prefilled route (rabble's PR #4290 review feedback) — the BLoCs reset to `initial` in that case, and the old gate keyed on the immutable initialQuery would have re-introduced the #3023 "infinite skeleton" symptom in the post-mount edit path. The app bar still dispatches *QueryChanged synchronously on mount for non-empty prefilled queries to skip the UI debounce (#3802 root cause). The same-query dedup guard inside each BLoC handler coalesces any late-arriving listener event; the listener is attached after the synchronous dispatch so the parent's pre-seeded controller text doesn't bounce through and double-fire. Closes #3802. Companion to parent epic #3801. Test plan: - search_results_view_test: new "field-edit transitions after mounting with a prefilled query" group adds three regression tests for rabble's case — a static empty-text+initial-state assertion, a static sub-min-length+initial-state assertion, and a live whenListen transition that flips from sections to idle when the user clears the field and the BLoC resets. Existing idle-state and filter-routing tests preserved; "with non-empty initialQuery" group renamed to "with non-empty search field text" and rewired to seed the controller directly. - search_results_app_bar_test: helper now constructs an external TextEditingController and passes it in (mirroring the page). Keeps main's three focus-contract tests and adds three dispatch tests (sync dispatch on non-empty initialQuery, no dispatch on empty initialQuery, no double-dispatch after the debounce window).
d0ab03b to
b3f304f
Compare
|
@rabble — addressed in b3f304f (force-pushed after a rebase onto fresh
Hoisted the final isIdle =
isInitialStatus && text.trim().length < minSearchQueryLength;…where Regression tests live in
Side effects of the hoist:
Local: |
This comment has been minimized.
This comment has been minimized.
rabble
left a comment
There was a problem hiding this comment.
Findings
Important: mobile/lib/screens/search_results/widgets/search_results_app_bar.dart:87 adds a new 300ms UI timer before dispatching user edits, but all four destination search BLoCs already debounce *QueryChanged by searchDebounceDuration / debounceRestartable (mobile/lib/constants/search_constants.dart:8, plus the on<*QueryChanged> handlers). That doubles the delay for normal typing and clearing inside the search results screen. In particular, clearing or reducing a prefilled query below minSearchQueryLength can leave stale sections/results visible for roughly 600ms before the BLoCs reset to initial. The previous app bar dispatched controller changes immediately and let the BLoCs own debounce; this PR should keep that model and only avoid firing the pre-seeded controller text twice.
Minor: mobile/lib/screens/search_results/widgets/search_results_app_bar.dart:57 still has an inaccurate comment. Dispatching in initState does not make the BLoCs “leave initial this frame” because the BLoC transformers still debounce the event, and the same-query dedup note is not true for all four blocs (UserSearchBloc explicitly has no same-query skip).
Verification: reviewed current PR head b3f304fc4; ran flutter test test/screens/search_results/view/search_results_view_test.dart test/screens/search_results/widgets/search_results_app_bar_test.dart from mobile/ and all 23 tests passed.
… query Tapping into Explore and typing 2+ characters auto-pushes /search-results/:query, but the destination flashed "Enter a search query / Discover something interesting" for ~300-600ms before section skeletons appeared. On slow networks this read as a hang. Hoist the search field's TextEditingController into a stateful _SearchResultsBody so both the app bar (writer) and the view (reader) operate on the same live text. The view drives its idle gate from the live controller value rather than the route arg, so the gate also returns to the placeholder when the user clears or shortens the field below minSearchQueryLength after landing on a prefilled route (rabble's PR #4290 review feedback) — the BLoCs reset to `initial` in that case, and the old gate keyed on the immutable initialQuery would have re-introduced the #3023 "infinite skeleton" symptom in the post-mount edit path. The app bar still dispatches *QueryChanged synchronously on mount for non-empty prefilled queries to skip the UI debounce (#3802 root cause). The same-query dedup guard inside each BLoC handler coalesces any late-arriving listener event; the listener is attached after the synchronous dispatch so the parent's pre-seeded controller text doesn't bounce through and double-fire. Closes #3802. Companion to parent epic #3801. Test plan: - search_results_view_test: new "field-edit transitions after mounting with a prefilled query" group adds three regression tests for rabble's case — a static empty-text+initial-state assertion, a static sub-min-length+initial-state assertion, and a live whenListen transition that flips from sections to idle when the user clears the field and the BLoC resets. Existing idle-state and filter-routing tests preserved; "with non-empty initialQuery" group renamed to "with non-empty search field text" and rewired to seed the controller directly. - search_results_app_bar_test: helper now constructs an external TextEditingController and passes it in (mirroring the page). Keeps main's three focus-contract tests and adds three dispatch tests (sync dispatch on non-empty initialQuery, no dispatch on empty initialQuery, no double-dispatch after the debounce window).
The PR head added a 300ms UI debounce on top of each search BLoC's existing `debounceRestartable` (also 300ms), doubling user-perceived latency on typing and clearing — most visibly leaving stale sections visible for ~600ms after clearing a prefilled query. Restore the pre-PR immediate-dispatch model so the BLoCs own the only debounce. Also rewrites the load-bearing initState comment, which incorrectly claimed the BLoCs "leave initial this frame" (debounceRestartable queues the event behind a 300ms timer) and cited a same-query dedup guard that UserSearchBloc explicitly does not have. Adds a _lastDispatchedQuery guard so the TextField's EditableText-driven selection updates don't fire duplicate *QueryChanged events. Addresses review #4306847831.
b3f304f to
41dbd28
Compare
|
@rabble — addressed both findings from your review (#4290 (review)) in 41dbd28.
Dropped the
Rewrote the comment. The new version explains that the synchronous dispatch is needed because the listener is attached after the parent seeds the controller (so the listener never sees the initial value), and that it starts each BLoC's 300ms Verification (from
|
Mobile PR PreviewPreview refreshed for Last refresh:
|
NotThatKindOfDrLiz
left a comment
There was a problem hiding this comment.
Re-reviewed current head 41dbd28. The live-controller idle gate fix and removal of the extra UI debounce address the earlier blocking issues, and the added regression coverage covers the post-mount clear/sub-min-length paths. Focused local verification and current PR checks are green.
|
@rabble this is ready for another look |
rabble
left a comment
There was a problem hiding this comment.
Re-reviewed current head 41dbd28. The live-controller idle gate addresses the post-mount clear/sub-min-length path, the extra UI debounce is gone, prior threads are resolved/outdated, CI is green, and local Resolving dependencies...
Downloading packages...
_fe_analyzer_shared 91.0.0 (100.0.0 available)
_flutterfire_internals 1.3.66 (1.3.71 available)
alchemist 0.12.1 (0.14.0 available)
analyzer 8.4.0 (13.0.0 available)
analyzer_buffer 0.1.11 (0.3.2 available)
analyzer_plugin 0.13.10 (0.14.9 available)
! app_device_integrity 1.1.0 from path overrides/app_device_integrity-1.1.0 (overridden)
app_links 6.4.1 (7.0.0 available)
archive 4.0.7 (4.0.9 available)
async 2.13.0 (2.13.1 available)
audio_session 0.2.2 (0.2.3 available)
bip340 0.3.0 (0.3.1 available)
bloc 9.2.0 (9.2.1 available)
build 4.0.3 (4.0.6 available)
build_config 1.2.0 (1.3.0 available)
build_runner 2.10.5 (2.15.0 available)
built_value 8.12.1 (8.12.6 available)
cli_launcher 0.3.2+1 (0.3.3+1 available)
cli_util 0.4.2 (0.5.1 available)
code_assets 1.0.0 (1.1.0 available)
code_builder 4.11.0 (4.11.1 available)
connectivity_plus 7.0.0 (7.1.1 available)
connectivity_plus_platform_interface 2.0.1 (2.1.0 available)
cookie_jar 4.0.8 (4.0.9 available)
cross_file 0.3.5+1 (0.3.5+2 available)
! cryptography_flutter 2.3.2 from path overrides/cryptography_flutter-2.3.2 (overridden)
cupertino_icons 1.0.8 (1.0.9 available)
custom_lint_core 0.8.1 (0.8.2 available)
custom_lint_visitor 1.0.0+8.4.0 (1.0.0+9.0.0 available)
dart_style 3.1.3 (3.1.9 available)
dev_build 1.1.3+1 (1.1.7+1 available)
! device_info_plus 10.1.2 (overridden) (13.1.0 available)
device_info_plus_platform_interface 7.0.3 (8.1.0 available)
dio 5.9.0 (5.9.2 available)
dio_cookie_manager 3.3.0 (3.4.0 available)
dio_web_adapter 2.1.1 (2.1.2 available)
drift 2.31.0 (2.33.0 available)
drift_dev 2.31.0 (2.33.0 available)
drift_flutter 0.2.8 (0.3.0 available)
ffi 2.1.5 (2.2.0 available)
file_picker 10.3.8 (11.0.2 available)
file_selector_android 0.5.2+4 (0.5.2+6 available)
firebase_analytics 12.1.2 (12.4.1 available)
firebase_analytics_platform_interface 5.0.6 (6.0.1 available)
firebase_analytics_web 0.6.1+2 (0.6.1+7 available)
firebase_core 4.4.0 (4.9.0 available)
firebase_core_platform_interface 6.0.2 (7.0.1 available)
firebase_core_web 3.4.0 (3.7.0 available)
firebase_crashlytics 5.0.7 (5.2.2 available)
firebase_crashlytics_platform_interface 3.8.17 (3.8.22 available)
firebase_messaging 16.1.1 (16.2.2 available)
firebase_messaging_platform_interface 4.7.6 (4.7.11 available)
firebase_messaging_web 4.1.2 (4.1.7 available)
firebase_performance 0.11.1+4 (0.11.4+1 available)
firebase_performance_platform_interface 0.1.6+4 (0.2.0+1 available)
firebase_performance_web 0.1.8+2 (0.1.8+7 available)
flutter_local_notifications 19.5.0 (21.0.0 available)
flutter_local_notifications_linux 6.0.0 (8.0.0 available)
flutter_local_notifications_platform_interface 9.1.0 (11.0.0 available)
flutter_local_notifications_windows 1.0.3 (3.0.0 available)
flutter_plugin_android_lifecycle 2.0.33 (2.0.34 available)
flutter_riverpod 3.0.3 (3.3.1 available)
flutter_secure_storage 9.2.4 (10.2.0 available)
flutter_secure_storage_linux 1.2.3 (3.0.0 available)
flutter_secure_storage_macos 3.1.3 (4.0.0 available)
flutter_secure_storage_platform_interface 1.1.2 (2.0.1 available)
flutter_secure_storage_web 1.2.1 (2.1.1 available)
flutter_secure_storage_windows 3.1.2 (4.2.1 available)
flutter_svg 2.2.3 (2.3.0 available)
flutter_web_auth_2 4.1.0 (5.0.2 available)
flutter_web_auth_2_platform_interface 4.1.0 (5.0.0 available)
freezed 3.2.3 (3.2.5 available)
go_router 16.3.0 (17.2.3 available)
golden_toolkit 0.15.0 (discontinued)
google_fonts 6.3.3 (8.1.0 available)
gtk 2.1.0 (2.2.0 available)
hive_ce 2.16.0 (2.19.3 available)
hive_ce_flutter 2.3.3 (2.3.4 available)
hive_ce_generator 1.10.0 (1.11.2 available)
hooks 1.0.3 (2.0.0 available)
image 4.7.2 (4.8.0 available)
image_picker 1.2.1 (1.2.2 available)
image_picker_android 0.8.13+10 (0.8.13+17 available)
image_picker_ios 0.8.13+3 (0.8.13+6 available)
isolate_channel 0.2.2+1 (0.6.1 available)
jni 0.15.2 (1.0.0 available)
js 0.6.7 (0.7.2 available)
json_annotation 4.9.0 (4.12.0 available)
json_serializable 6.11.2 (6.14.0 available)
lints 6.0.0 (6.1.0 available)
matcher 0.12.19 (0.12.20 available)
melos 7.3.0 (7.7.0 available)
! meta 1.17.0 (overridden) (1.18.2 available)
mockito 5.6.4 (5.7.0 available)
mocktail 1.0.4 (1.0.5 available)
mustache_template 2.0.2 (2.0.4 available)
native_toolchain_c 0.17.6 (0.19.0 available)
package_info_plus 9.0.0 (10.1.0 available)
package_info_plus_platform_interface 3.2.1 (4.1.0 available)
path_provider_android 2.2.22 (2.3.1 available)
path_provider_foundation 2.5.1 (2.6.0 available)
patrol 4.2.0 (4.5.0 available)
patrol_finders 3.1.0 (3.4.0 available)
patrol_log 0.7.1 (0.9.0 available)
petitparser 7.0.1 (7.0.2 available)
pointycastle 3.9.1 (4.0.0 available)
posix 6.0.3 (6.5.0 available)
postgres 3.5.9 (3.5.11 available)
pro_video_editor 1.16.2 (1.16.3 available)
process_run 1.2.4 (1.3.3+1 available)
qr 3.0.2 (4.0.0 available)
riverpod 3.0.3 (3.2.1 available)
riverpod_analyzer_utils 1.0.0-dev.7 (1.0.0-dev.10 available)
riverpod_annotation 3.0.3 (4.0.2 available)
riverpod_generator 3.0.3 (4.0.3 available)
! rxdart 0.28.0 (overridden)
share_plus 12.0.1 (13.1.0 available)
share_plus_platform_interface 6.1.0 (7.1.0 available)
shared_preferences 2.5.4 (2.5.5 available)
shared_preferences_android 2.4.18 (2.4.23 available)
shared_preferences_platform_interface 2.4.1 (2.4.2 available)
shelf_web_socket 2.0.1 (3.0.0 available)
skeletonizer 2.1.2 (2.1.3 available)
source_gen 4.1.1 (4.2.3 available)
source_helper 1.3.8 (1.3.12 available)
source_span 1.10.1 (1.10.2 available)
sqflite 2.4.2 (2.4.2+1 available)
sqflite_android 2.4.2+2 (2.4.2+3 available)
sqflite_common 2.5.6 (2.5.8 available)
sqflite_common_ffi 2.3.6 (2.4.0+3 available)
sqflite_common_ffi_web 0.4.5+4 (1.1.1 available)
sqlite3 2.9.4 (3.3.1 available)
sqlite3_flutter_libs 0.5.41 (0.6.0+eol available)
sqlparser 0.43.1 (0.44.4 available)
synchronized 3.4.0 (3.4.0+1 available)
test 1.30.0 (1.31.1 available)
test_api 0.7.10 (0.7.12 available)
test_core 0.6.16 (0.6.18 available)
timezone 0.10.1 (0.11.0 available)
unique_names_generator 3.0.0 (3.1.2 available)
url_launcher_android 6.3.28 (6.3.30 available)
url_launcher_ios 6.3.6 (6.4.1 available)
url_launcher_web 2.4.1 (2.4.3 available)
uuid 4.5.2 (4.5.3 available)
vector_graphics 1.1.19 (1.2.2 available)
vector_graphics_compiler 1.1.19 (1.2.3 available)
vector_math 2.2.0 (2.3.0 available)
video_player 2.10.1 (2.11.1 available)
video_player_android 2.9.1 (2.9.5 available)
video_player_avfoundation 2.8.8 (2.9.7 available)
video_player_platform_interface 6.6.0 (6.7.0 available)
vm_service 15.0.2 (15.2.0 available)
volume_controller 3.4.4 (3.5.0 available)
wakelock_plus 1.5.2 (1.6.1 available)
wakelock_plus_platform_interface 1.5.0 (1.5.1 available)
watcher 1.2.0 (1.2.1 available)
web_socket 0.1.6 (1.0.1 available)
webview_flutter_android 4.10.13 (4.12.0 available)
webview_flutter_platform_interface 2.14.0 (2.15.1 available)
webview_flutter_wkwebview 3.24.1 (3.25.1 available)
win32 5.15.0 (6.2.0 available)
win32_registry 1.1.5 (3.0.3 available)
xml 6.6.1 (7.0.1 available)
yaml_edit 2.2.3 (2.2.4 available)
Got dependencies!
1 package is discontinued.
160 packages have newer versions incompatible with dependency constraints.
Try flutter pub outdated for more information.
00:00 +0: loading /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_view_test.dart
00:00 +0: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_view_test.dart: SearchResultsView renders all sections when filter is all
00:04 +1: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:05 +2: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:05 +3: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:06 +4: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:06 +5: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:06 +6: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:07 +7: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:07 +8: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:07 +9: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:07 +10: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:07 +11: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:08 +12: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:08 +13: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:08 +14: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:08 +15: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:09 +16: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/view/search_results_page_test.dart: SearchResultsPage shows the empty-query idle placeholder in default mode
00:09 +17: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/section_header_test.dart: SectionHeader renders title text
00:09 +18: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/section_header_test.dart: SectionHeader renders title text
00:09 +19: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/section_header_test.dart: SectionHeader renders title text
00:10 +20: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:11 +21: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:11 +22: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:11 +23: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:12 +24: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:12 +25: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:12 +26: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_error_state_test.dart: SearchSectionErrorState renders warning icon
00:13 +27: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_sheet_test.dart: SearchFilterSheet shows all filter options
00:14 +28: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_sheet_test.dart: SearchFilterSheet shows all filter options
00:14 +29: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_sheet_test.dart: SearchFilterSheet shows all filter options
00:14 +30: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_sheet_test.dart: SearchFilterSheet shows all filter options
00:17 +31: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/videos_section_test.dart: VideosSection showAll: false (All tab preview) hides entirely when success with empty results
00:18 +32: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/videos_section_test.dart: VideosSection showAll: false (All tab preview) hides entirely when success with empty results
00:19 +33: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/videos_section_test.dart: VideosSection showAll: false (All tab preview) hides entirely when success with empty results
00:19 +34: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:19 +35: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:20 +36: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:21 +37: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:21 +38: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:22 +39: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:23 +40: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:23 +41: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:23 +42: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:23 +43: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_results_app_bar_test.dart: SearchResultsAppBar renders SearchFilterPill
00:24 +44: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/lists_section_test.dart: ListsSection showAll: false (All tab preview) hides entirely when success with empty results
00:24 +45: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/lists_section_test.dart: ListsSection showAll: false (All tab preview) hides entirely when success with empty results
00:24 +46: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/lists_section_test.dart: ListsSection showAll: false (All tab preview) hides entirely when success with empty results
00:25 +47: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/lists_section_test.dart: ListsSection showAll: false (All tab preview) hides entirely when success with empty results
00:25 +48: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:25 +49: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:26 +50: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:26 +51: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:26 +52: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:27 +53: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:27 +54: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:27 +55: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:27 +56: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:27 +57: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:28 +58: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_section_empty_state_test.dart: SearchSectionEmptyState renders search icon
00:28 +59: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_pill_test.dart: SearchFilterPill renders "All" label when filter is all
00:28 +60: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_pill_test.dart: SearchFilterPill renders "All" label when filter is all
00:28 +61: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_pill_test.dart: SearchFilterPill renders "All" label when filter is all
00:28 +62: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/search_filter_pill_test.dart: SearchFilterPill renders "All" label when filter is all
00:31 +63: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:32 +64: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:32 +65: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:32 +66: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:33 +67: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:34 +68: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:34 +69: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: false (All tab preview) hides entirely when success with empty results
00:35 +70: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:35 +71: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:36 +72: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:36 +73: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:36 +74: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:37 +75: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) hides entirely when success with empty results
00:37 +76: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection showAll: true (dedicated tab) renders SearchSectionErrorState on failure
00:37 +77: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders header and content when success with results
00:37 +78: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders header and content when success with results
00:37 +79: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders header and content when success with results
00:37 +80: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders header and content when success with results
00:37 +81: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection degraded-empty (#3791) hides section entirely in All tab preview when truly empty
00:37 +82: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders SearchSectionErrorState on failure
00:37 +83: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: false (All tab preview) renders SearchSectionErrorState on failure
00:38 +84: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection loading more indicator shows loading indicator when showAll and isLoadingMore
00:38 +85: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: true (dedicated tab) renders SearchSectionEmptyState when success with empty results
00:38 +86: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: true (dedicated tab) renders SearchSectionEmptyState when success with empty results
00:38 +87: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/people_section_test.dart: PeopleSection loading more indicator does not show loading indicator when not showAll
00:38 +88: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection showAll: true (dedicated tab) renders SearchSectionErrorState on failure
00:38 +89: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection retry dispatches HashtagSearchQueryChanged with current query
00:38 +90: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection loading more indicator shows loading indicator when showAll and isLoadingMore
00:38 +91: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection loading more indicator hides loading indicator when showAll and not isLoadingMore
00:38 +92: /Users/rabble/code/divine/divine-mobile/.claude/worktrees/search-video-sort/mobile/test/screens/search_results/widgets/tags_section_test.dart: TagsSection loading more indicator does not show loading indicator when not showAll
00:39 +93: All tests passed! passes.
Summary
Tapping into Explore's search bar and typing 2+ characters auto-pushes
/search-results/:queryafter a 300ms debounce. The destination then flashed "Enter a search query / Discover something interesting" for another ~300–600ms before BLoCs were dispatched and section skeletons appeared. On slow networks the window stretched, reading as a flash, error, or hang. This PR makes the destination drop the idle placeholder when a route arg query is pending, and dispatch*QueryChangedevents synchronously on AppBar mount so the BLoCs advance frominitialthis same frame.Closes #3802. Companion to parent epic #3801.
Root cause
SearchResultsViewkeyed the idle short-circuit onVideoSearchBloc.state.status == initialalone. That signal correctly identifies "no query entered" (the load-bearing PR #3199 fix for #3023's infinite skeleton) but does NOT distinguish it from "query just arrived as a route arg, AppBar dispatch is still pending its 300ms UI debounce."Changes
lib/screens/search_results/view/search_results_view.dart— add optionalString initialQuery = ''. Gate the idle short-circuit oninitialQuery.trim().length < minSearchQueryLengthso it matches the BLoC handlers' own gate exactly. Falls through to sections (whose own skeletons render oninitial || loading/searchingwith empty results) when a searchable query is pending. Inline comment cites PR fix(search): replace empty-query skeleton with initial-state placeholder #3199 / fix: skeleton loader shown indefinitely for empty search query #3023 so a future reader doesn't simplify the conditional back to the buggy form.lib/screens/search_results/view/search_results_page.dart— passinitialQueryfrom_SearchResultsBodytoSearchResultsView(1 line).lib/screens/search_results/widgets/search_results_app_bar.dart— reorderinitStateso the controller text is seeded BEFORE the listener is attached. WheninitialQuery.isNotEmpty, dispatch*QueryChangedevents synchronously to all four BLoCs. The BLoCs'debounceRestartablestill applies; same-query dedup guards coalesce any late-arriving event. Focus model intentionally untouched — that's sibling issue fix: keyboard collapses when navigating to search page when typing #3020 / search: define canonical focus behavior across Explore and Search Results #3803 territory.All 5 entry points to
SearchResultsPagego throughSearchResultsPage(initialQuery: query)(Explore auto-push,@mentionlink tap, universal link, deep link, GoRoute), so this fix applies uniformly.Commit history
1f99c2b6— initial fix.2407333d— self-review follow-up: tighten the view's idle gate fromisEmptytotrim().length < minSearchQueryLengthso reachable edge cases (whitespace-only or 1-char deep links like/search-results/aand/search-results/%20%20) don't render infinite skeletons (a fix: skeleton loader shown indefinitely for empty search query #3023-class regression in the corner). Two extra regression tests pin the corrected behavior.Scope clarification
Per the issue body: "Do not collapse this issue into keyboard/focus behavior. They are related, but not identical." This PR is scoped to the intermediate-state symptom only. The focus and keyboard work belongs to:
The parent epic #3801 calls for these three children to land as one cohesive transition PR (Gate 2). I scoped this PR to #3802 alone; bundling is the epic driver's call.
Test plan
flutter test test/screens/search_results/→ 85 passingflutter test test/blocs/{video,user,hashtag,list}_search test/blocs/search_results_filter→ 119 passingflutter test test/screens/explore_screen_pure_test.dart→ 12 passingflutter analyze lib test integration_test→ no issuesdart format→ cleanNew test coverage:
search_results_view_test.dart— newwith non-empty initialQuerygroup assertsSearchSectionInitialStatedoes NOT render wheninitialQueryis non-empty + status is initial, and that the all-filter section list renders instead. Plus two regression tests pinning the whitespace-only and sub-min-length cases to the idle placeholder (cures the fix: skeleton loader shown indefinitely for empty search query #3023-class edge case).search_results_app_bar_test.dart— adds missing_MockListSearchBlocmock (the existing setUp had a gap), plus three new tests: synchronous dispatch on non-emptyinitialQuery, NO dispatch on emptyinitialQuery, and no double-dispatch after the 300ms debounce window elapses.Manual device testing guide
What changed (one-paragraph context for QA)
When you typed 2+ characters in Explore's search bar and the app auto-pushed to
/search-results/:query, you saw a brief "Enter a search query / Discover something interesting" placeholder for ~300–600ms before search results loaded — especially noticeable on slow networks. After this fix, the destination shows section skeletons immediately and lands on real results once they arrive. The empty-query case (opening the search screen directly) still shows the "Enter a search query" placeholder as before.Build
Pick one:
Mobile PR Previewlink from this PR's checks. The bot posts ahttps://<hash>.openvine-app.pages.devURL on every push.Notes:
releasethandebug(debug is slow anyway, so any flash is harder to attribute).Golden path — primary repro from issue #3802
hello(or any 2+ character query). Stop typing and wait.What to look for (frame-by-frame):
Tip: if you can't catch it visually, screen-record at 60 fps and step through frames.
Slow-network case — the issue's "feels like a hang" complaint
Same steps as Golden path, but throttle to Slow 3G first.
Direct entry to /search-results (regression check for #3023 — DO NOT skip)
This is the load-bearing case from PR #3199. This must still work the OLD way.
Two ways to trigger:
A. Universal/deep link with empty query — open the app from a
divine.video/search-results/link (no query) if you can construct one, ORB. In the running app: Explore → tap search bar → land on
/search-results/with no query in the field.Edge cases — sub-min-length & whitespace-only query (the F1 finding addressed in commit 2)
Reachable via deep / universal links — try if you can construct them, otherwise skip:
divine.video/search-results/a(single character) — or build a URL pointing at/search-results/aand open it from another app.divine.video/search-results/%20%20%20(whitespace-only, URL-encoded).length >= 2so sub-min queries never reach the network; the view correctly treats them as idle.)Other entry paths — same expected behavior
These all converge through
SearchResultsPage(initialQuery: ...)so they should all behave consistently:@usernamemention in a comment (links vialinkified_text) — should land on search with the username pre-filled, no misleading placeholder.https://divine.video/search-results/<query>— same.Focus / keyboard — explicitly NOT in scope here
This fix does NOT change focus or keyboard behavior. Those are tracked separately in:
For #3802 QA: don't flag keyboard or focus issues against this PR. The destination field will not auto-focus when navigated with a pre-filled query — that's the existing behavior and intentionally untouched.
Filters & deep search — confirm nothing else regressed
/search-results/hello(golden path).Empty results — make sure we don't show idle instead of empty
xqzqzqzq).Error/timeout (people search) — confirm degraded-empty UI
Same as above on a flaky network. If NIP-50 user search times out:
Sign-off checklist
If any row fails, file a comment on this PR with the device, OS, network condition, and a screen recording.
Notes for reviewers
isIdleconditional insearch_results_view.dartis intentional —tests-guard-intentional-workaroundsprecedent. Don't simplify it back toisInitialStatusalone or fix: skeleton loader shown indefinitely for empty search query #3023 reopens.initialQuery.trim().length < minSearchQueryLengthrather thanisEmptyso it mirrors the BLoC handlers' own gate exactly. The looserisEmptyform regressed reachable edge cases in commit 1; commit 2 tightened it.@mention-link entry paths still benefit from this fix.