Fix SelectableRegion crash when the selection starts in a scrollable child but does not select anything initially#184420
Conversation
…he scrollable but nothing is selected initially
…use they are possible, previously we were checking _selectionStartsInScrollable and it could only be true or false and false represented both set and uninitialized states, but after becoming nullable null represents uninitialized and false represents set, we should handle this new state the same as if it were false and fail gracefully
There was a problem hiding this comment.
Code Review
This pull request converts _selectionStartsInScrollable to a nullable boolean to fix a crash when starting selections in scrollable padding and adds corresponding regression tests. Review feedback suggests consistently using the non-null assertion operator ! in logic where the variable is guaranteed to be non-null to improve code conciseness and consistency.
|
I created another PR because I somehow managed to break my old one (and a few others). Apologies for any confusion. |
| assert(!_selectionStartsInScrollable); | ||
| _selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition); | ||
| if (_currentDragStartRelatedToOrigin == null && _currentDragEndRelatedToOrigin == null) { | ||
| _selectionStartsInScrollable ??= _globalPositionInScrollable(event.globalPosition); |
There was a problem hiding this comment.
Just curious, why ??= instead of =?
My expectation would be that if both _currentDragStartRelatedToOrigin and _currentDragEndRelatedToOrigin are null, then _selectionStartsInScrollable is also null?
There was a problem hiding this comment.
Ahh this comment cleared up my confusion: #115787 (comment)
The problem here is that handleSelectWord is called before handleSelectionEdgeUpdate, and handleSelectWord will set _selectionStartsInScrollable but not _currentDragStartRelatedToOrigin/_currentDragEndRelatedToOrigin.
There was a problem hiding this comment.
@Renzo-Olivares What do you think of adding a comment like:
// _selectionStartsInScrollable might already be set by some other event,
// like [handleSelectWord].That would help clarify why ??= is needed here.
There was a problem hiding this comment.
Can you remind me the conclusion from the previous conversation that either select word on empty space should set the selection edge to the closest fragment or not set _selectionStartsInScrollable to true?
my original thought is that if there is a selection, one of the selection edge must not be null. I think we should keep this contract.
so depending on the behavior of select word on empty space.
- if this counts as not creating a selection, both edge should be null and _selectionStartsInScrollable should stay false
- If this counts as creating a selection, both edge should be set to collapse some where in the selectable, and _selectionStartsInScrollable should be set to true.
There was a problem hiding this comment.
@chunhtai We did not conclude our previous conversation in the old PR #184164 (comment). Sorry I should have added a note about that.
Thanks for explaining your original thought process. After reading it, I definitely agree that we should keep the original contract intact.
Instead of collapsing the selection, I'm leaning towards selecting the nearest word. Here is my reasoning:
Currently, SelectableRegion doesn't check if a selection is collapsed before calling showToolbar or showHandles. If we were to collapse the selection here, we'd have to add those checks to prevent the selection overlay from showing up on a collapsed selection.
Maybe it's okay to add these checks but, it shifts our current assumptions. Right now, when gesture callbacks trigger in SelectableRegion, we assume that dispatching an event to the selection tree will result in a valid selection (e.g., SelectWordSelectionEvent will actually select a word). Adding checks for a collapsed selection at the SelectableRegion level implies a new state: 'we won the arena and consumed the gesture, but the selection tree potentially failed to set the type of selection we expected.' If the delegate handles this and selects the nearest word we keep the original contract, 'we won the arena and consumed the gesture, and the selection tree set the expected selection'.
Is that reasonable?
Some additional notes:
On the web the behavior generally selects the nearest word even when on empty padding (there is some nuance to this, for example double-clicking in empty padding above the first line of a multi-line text always selects the first word, and double-clicking under the empty padding below the last line selects nothing). On mobile chrome the nearest word is selected on the first line when doing a gesture in the empty padding so this seems to very by platform. https://jsfiddle.net/jbw1ck84/2/ for a demo
On jetpack compose with SelectionContainer a gesture on the empty padding is consumed by the Scrollable.
There was a problem hiding this comment.
Thanks for the excellent demo! This behavior sounds good to me, but I'll defer to @chunhtai as they have more context on selection invariants.
There was a problem hiding this comment.
we assume that dispatching an event to the selection tree will result in a valid selection (e.g., SelectWordSelectionEvent will actually select a word)
Can you point me to the code where we made this assumption? I find it weird that we always expect a word is selected, but maybe I forgot some assumptions i made before.
As for whether select nearest word, I don't have strong opinion. I think select word at an empty space doesn't seem like a normal gesture where user will expect a specific result.
There was a problem hiding this comment.
For example at the end of a long press we always call _showToolbar and do not confirm whether a word was actually selected.
flutter/packages/flutter/lib/src/widgets/selectable_region.dart
Lines 1020 to 1028 in e4ae4e6
Similarly for a double-click on tap up,
flutter/packages/flutter/lib/src/widgets/selectable_region.dart
Lines 942 to 966 in e4ae4e6
This is similar to TextSelectionGestureDetector, i.e. a call to showToolbar will always show the toolbar as long as a selection overlay is present.
flutter/packages/flutter/lib/src/widgets/text_selection.dart
Lines 2930 to 2937 in e4ae4e6
All this to say i'm not against adding logic that says "is our selection uncollapsed after selecting a word?, if so then show the toolbar, if not then do not show anything" but it does add more cases to handle.
loic-sharma
left a comment
There was a problem hiding this comment.
Looks good to me!
I left some questions, but those are mainly because I don't have a strong understanding of selectable scrollables.
|
I still have concern over this #184420 (comment) |
…hey still produce a valid selection
…try in RenderParagraph
…in-scrollable-fix
chunhtai
left a comment
There was a problem hiding this comment.
LGTM, left a suggestion
| // No selectable's bounding box contained the position. Clamp to the nearest | ||
| // selectable so that the boundary selection event always produces a valid selection. | ||
| if (selectables.isNotEmpty) { | ||
| final int nearestIndex = _closestSelectableIndexTo(effectiveGlobalPosition); |
There was a problem hiding this comment.
yeah, you are right I missed we bypasses the selectable based on bounding box
| // selectable so that the boundary selection event always produces a valid selection. | ||
| if (selectables.isNotEmpty) { | ||
| final int nearestIndex = _closestSelectableIndexTo(effectiveGlobalPosition); | ||
| final SelectionGeometry existingGeometry = selectables[nearestIndex].value; |
There was a problem hiding this comment.
Not blocking the merge, but can this and line 3020 be refactor out?
There was a problem hiding this comment.
Do you mean if we can do the calculation for _closestSelectableIndex inside the first loop?
There was a problem hiding this comment.
I meant a share helper method
There was a problem hiding this comment.
I tried doing a shared helper method for the boundingBoxes loop but it felt a little clunky since we do extra work in the _closestSelectableIndexTo method and have two different outputs in both loops. Though maybe i'm not following what part should be the share helper method.
I did try some clean up:
and
The latter eliminates _closestSelectableIndex and tracks the nearest index in line in the first pass of the boundingBoxes. I think the calculations we do with clampDouble are negligible.
There was a problem hiding this comment.
Oh I think @chunhtai was suggesting a helper for the "Dispatch a selection event to a child and reset other selectables if the child's SelectionGeometry changed" logic. I don't feel strongly either-ways though.
There was a problem hiding this comment.
I'll land this as is for now, I don't feel strongly either.
8df91c6
flutter/flutter@3d0e822...5e4f169 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from f12c89580766 to 11640d1cbc5c (3 revisions) (flutter/flutter#185418) 2026-04-22 engine-flutter-autoroll@skia.org Roll Packages from 7c8e13e to 4a2091d (2 revisions) (flutter/flutter#185417) 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from f765937d0639 to f12c89580766 (1 revision) (flutter/flutter#185410) 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from 635b78342e75 to f765937d0639 (1 revision) (flutter/flutter#185406) 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from cda2af3f5c2e to 635b78342e75 (3 revisions) (flutter/flutter#185393) 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from 632a41e2baba to cda2af3f5c2e (3 revisions) (flutter/flutter#185390) 2026-04-22 engine-flutter-autoroll@skia.org Roll Skia from 019de7776cfa to 632a41e2baba (3 revisions) (flutter/flutter#185383) 2026-04-22 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Update libimobiledevice and dependencies (#181932)" (flutter/flutter#185385) 2026-04-22 rmolivares@renzo-olivares.dev Fix SelectableRegion crash when the selection starts in a scrollable child but does not select anything initially (flutter/flutter#184420) 2026-04-21 34871572+gmackall@users.noreply.github.com Fix timeout when `hybrid_android_views` fails `MotionEvent recomposition` (flutter/flutter#185003) 2026-04-21 srawlins@google.com [flutter] Remove dead check on null being in a set of non-nullables (flutter/flutter#184100) 2026-04-21 737941+loic-sharma@users.noreply.github.com Update the text input triage process to route to platform teams (flutter/flutter#185225) 2026-04-21 scheglov@google.com Compatibility bridge for analyzer 12 and 13. (flutter/flutter#185360) 2026-04-21 magder@google.com new_gallery_macos_impeller__transition_perf out of bringup (flutter/flutter#185355) 2026-04-21 engine-flutter-autoroll@skia.org Roll Skia from 21789d5e2fee to 019de7776cfa (9 revisions) (flutter/flutter#185365) 2026-04-21 magder@google.com Update libimobiledevice and dependencies (flutter/flutter#181932) 2026-04-21 magder@google.com platform_view_macos_impeller__start_up out of bringup (flutter/flutter#185354) 2026-04-21 magder@google.com complex_layout_scroll_perf_macos_impeller__timeline_summary out of bringup (flutter/flutter#185356) 2026-04-21 engine-flutter-autoroll@skia.org Roll Fuchsia Linux SDK from LPa7NLiXEZP2A7IwZ... to UdpQnaP5eSaDZd3-i... (flutter/flutter#185359) 2026-04-21 engine-flutter-autoroll@skia.org Roll Packages from 01c505f to 7c8e13e (4 revisions) (flutter/flutter#185361) 2026-04-21 737941+loic-sharma@users.noreply.github.com Improve the error if the tool cannot find the locally built engine (flutter/flutter#184546) 2026-04-21 engine-flutter-autoroll@skia.org Roll Skia from a234f0ed7245 to 21789d5e2fee (1 revision) (flutter/flutter#185349) 2026-04-21 victorsanniay@gmail.com Replace IndexedStack visibility children with _VisibilityScope + ExcludeFocus (flutter/flutter#184884) 2026-04-21 dacoharkes@google.com [data_assets] Try fix #184505 (flutter/flutter#185330) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC stuartmorgan@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Original discussion: #184164
Fixes #115787
Before this change sometimes when starting a selection gesture such as a long press drag or a double tap drag directly on the empty padding of a
Scrollablethe selection may not be set initially because there was nothing to select in the empty padding. This caused a crash when receiving subsequent edge update events because we believe the selection starts in the scrollable but we do not have any selection edges set.To fix this I made
_selectionStartsInScrollablenullable, this way a subsequent selection edge will not try to set_selectionStartsInScrollableagain if it has already been set removing the need for the assert.handleSelectionEdgeUpdatepreviously assumed that if the cached edges were not set then we should set_selectionStartsInScrollable, the assert was used to verify that_selectionStartsInScrollablewas false i.e. had not been set/changed from its initial value. By making_selectionStartsInScrollablenullable we can ensure a subsequent edge update event does not re-set the value if it has already been set previously.Pre-launch Checklist
///).