Skip to content

Fix SelectableRegion crash when the selection starts in a scrollable child but does not select anything initially#184420

Merged
auto-submit[bot] merged 16 commits intoflutter:masterfrom
Renzo-Olivares:selection-starts-in-scrollable-fix
Apr 22, 2026
Merged

Fix SelectableRegion crash when the selection starts in a scrollable child but does not select anything initially#184420
auto-submit[bot] merged 16 commits intoflutter:masterfrom
Renzo-Olivares:selection-starts-in-scrollable-fix

Conversation

@Renzo-Olivares
Copy link
Copy Markdown
Contributor

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 Scrollable the 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 _selectionStartsInScrollable nullable, this way a subsequent selection edge will not try to set _selectionStartsInScrollable again if it has already been set removing the need for the assert. handleSelectionEdgeUpdate previously assumed that if the cached edges were not set then we should set _selectionStartsInScrollable, the assert was used to verify that _selectionStartsInScrollable was false i.e. had not been set/changed from its initial value. By making _selectionStartsInScrollable nullable we can ensure a subsequent edge update event does not re-set the value if it has already been set previously.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

Renzo Olivares added 4 commits March 31, 2026 12:03
…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
@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. f: scrolling Viewports, list views, slivers, etc. labels Mar 31, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/flutter/lib/src/widgets/scrollable.dart Outdated
Comment thread packages/flutter/lib/src/widgets/scrollable.dart Outdated
Comment thread packages/flutter/lib/src/widgets/scrollable.dart Outdated
@Renzo-Olivares
Copy link
Copy Markdown
Contributor Author

I created another PR because I somehow managed to break my old one (and a few others). Apologies for any confusion.

Comment thread packages/flutter/test/widgets/scrollable_selection_test.dart Outdated
Comment thread packages/flutter/test/widgets/scrollable_selection_test.dart Outdated
assert(!_selectionStartsInScrollable);
_selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition);
if (_currentDragStartRelatedToOrigin == null && _currentDragEndRelatedToOrigin == null) {
_selectionStartsInScrollable ??= _globalPositionInScrollable(event.globalPosition);
Copy link
Copy Markdown
Member

@loic-sharma loic-sharma Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, why ??= instead of =?

My expectation would be that if both _currentDragStartRelatedToOrigin and _currentDragEndRelatedToOrigin are null, then _selectionStartsInScrollable is also null?

Copy link
Copy Markdown
Member

@loic-sharma loic-sharma Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

@loic-sharma loic-sharma Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. if this counts as not creating a selection, both edge should be null and _selectionStartsInScrollable should stay false
  2. 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.

Copy link
Copy Markdown
Contributor Author

@Renzo-Olivares Renzo-Olivares Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@chunhtai chunhtai Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example at the end of a long press we always call _showToolbar and do not confirm whether a word was actually selected.

void _handleTouchLongPressEnd(LongPressEndDetails details) {
_finalizeSelection();
_updateSelectedContentIfNeeded();
_finalizeSelectableRegionStatus();
_showToolbar();
if (defaultTargetPlatform == TargetPlatform.android) {
_showHandles();
}
}

Similarly for a double-click on tap up,

switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
hideToolbar();
_collapseSelectionAt(offset: details.globalPosition);
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
// On desktop platforms the selection is set on tap down.
}
case 2:
final bool isPointerPrecise = _isPrecisePointerDevice(details.kind);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
if (!isPointerPrecise) {
// On Android, a double tap will only show the selection overlay after
// the following tap up when the pointer device kind is not precise.
_showHandles();
_showToolbar();
}

This is similar to TextSelectionGestureDetector, i.e. a call to showToolbar will always show the toolbar as long as a selection overlay is present.

void onDoubleTapDown(TapDragDownDetails details) {
if (delegate.selectionEnabled) {
renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
if (shouldShowSelectionToolbar) {
editableText.showToolbar();
}
}
}

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.

Comment thread packages/flutter/lib/src/widgets/scrollable.dart
loic-sharma
loic-sharma previously approved these changes Apr 1, 2026
Copy link
Copy Markdown
Member

@loic-sharma loic-sharma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

I left some questions, but those are mainly because I don't have a strong understanding of selectable scrollables.

@loic-sharma loic-sharma added the CICD Run CI/CD label Apr 1, 2026
@chunhtai
Copy link
Copy Markdown
Contributor

chunhtai commented Apr 2, 2026

I still have concern over this #184420 (comment)

@justinmc justinmc requested review from chunhtai and removed request for chunhtai April 7, 2026 22:15
Comment thread packages/flutter/lib/src/widgets/selectable_region.dart Outdated
Comment thread packages/flutter/lib/src/widgets/selectable_region.dart Outdated
Comment thread packages/flutter/lib/src/widgets/selectable_region.dart
@Renzo-Olivares Renzo-Olivares requested a review from chunhtai April 15, 2026 21:14
loic-sharma
loic-sharma previously approved these changes Apr 20, 2026
Copy link
Copy Markdown
Member

@loic-sharma loic-sharma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-LGTM!

chunhtai
chunhtai previously approved these changes Apr 20, 2026
Copy link
Copy Markdown
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking the merge, but can this and line 3020 be refactor out?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean if we can do the calculation for _closestSelectableIndex inside the first loop?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant a share helper method

Copy link
Copy Markdown
Contributor Author

@Renzo-Olivares Renzo-Olivares Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

8df91c6

and

0257bab

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.

Copy link
Copy Markdown
Member

@loic-sharma loic-sharma Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll land this as is for now, I don't feel strongly either.

@Renzo-Olivares Renzo-Olivares dismissed stale reviews from chunhtai and loic-sharma via 8df91c6 April 21, 2026 01:39
@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems and removed CICD Run CI/CD labels Apr 21, 2026
@Renzo-Olivares
Copy link
Copy Markdown
Contributor Author

Apologies for the re-review, I decided to do a little clean up.

The changes are in:

8df91c6

and

0257bab

Copy link
Copy Markdown
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Copy Markdown
Member

@loic-sharma loic-sharma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-LGTM

@justinmc justinmc added the autosubmit Merge PR when tree becomes green via auto submit App label Apr 21, 2026
@auto-submit auto-submit Bot added this pull request to the merge queue Apr 22, 2026
Merged via the queue into flutter:master with commit 7220aca Apr 22, 2026
88 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Apr 22, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Apr 23, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems CICD Run CI/CD f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Selection] How To Avoid '!_selectionStartsInScrollable': is not true. assertion when using SelectionArea with scrollable widget.

5 participants