Skip to content

widgets: Guard _compareScreenOrder against unlaid-out selectables - #184900

Draft
davidmigloz wants to merge 5 commits into
flutter:masterfrom
davidmigloz:fix-selection-non-current-route
Draft

davidmigloz wants to merge 5 commits into
flutter:masterfrom
davidmigloz:fix-selection-non-current-route

Conversation

@davidmigloz

@davidmigloz davidmigloz commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

Relates to #151536

Description

When _RenderTheater skips layout for an obscured OverlayEntry (e.g., a non-current route behind an opaque route), selectables in that entry remain registered with their SelectionContainerDelegate. When _flushAdditions runs the merge-sort via _compareScreenOrder, it accesses paintBounds / getTransformTo / boundingBoxes.first on RenderBoxes that have no size, throwing StateError in release mode and AssertionError in debug.

This change makes geometry-acquisition failure explicit instead of crashing the sort: each _compareScreenOrder now reads geometry through a small Rect? helper whose on StateError / on AssertionError catch is scoped to just that read. The comparator handles null explicitly — unavailable selectables tie with each other and always sort after available ones — so comparisons involving unavailable selectables are mutually consistent and laid-out selectables keep their correct relative order. (An earlier revision of this PR returned 0 whenever either side lacked geometry; that tied an unlaid-out selectable with every laid-out one — an inconsistent order that List.sort and the merge logic aren't guaranteed to handle.)

Two methods are guarded because _flushAdditions can end up calling either override:

  • MultiSelectableSelectionContainerDelegate._compareScreenOrder in packages/flutter/lib/src/widgets/selectable_region.dart — used by top-level SelectionArea / SelectableRegion.
  • _SelectableTextContainerDelegate._compareScreenOrder in packages/flutter/lib/src/widgets/text.dart — used when a single Text / Text.rich has multiple _SelectableFragments (e.g., text with WidgetSpans).

Why catch AssertionError too

RenderBox.size evaluates _size ?? (throw StateError(...)). In release mode the StateError fires. In debug mode assert(hasSize) fires first and throws AssertionError before control ever reaches the throw. Catching only StateError would leave debug builds crashing while release silently recovers — the exact opposite of what we want. The on AssertionError clause keeps the guard symmetric across build modes.

Note: avoid_catching_errors is currently commented out in analysis_options.yaml (blocked on dart-lang/linter#4998), so the catch is lint-clean.

Testing

Red/green coverage of both catch branches, in both files

Four testWidgets cases directly exercise the on StateError and on AssertionError catch branches of both guarded methods by registering mock Selectables whose boundingBoxes getter throws. All four are proper red/green tests: they fail on master (the thrown error propagates out of _flushAdditions' post-frame callback and tester.takeException() reports it) and pass with this change (the helper returns null, unavailable selectables sort after available ones, and the sort completes).

  • packages/flutter/test/widgets/selectable_region_test.dart — mock Selectables under a top-level SelectableRegion route through MultiSelectableSelectionContainerDelegate._compareScreenOrder (the override in selectable_region.dart). One case per error type.
  • packages/flutter/test/widgets/text_test.dart — mock Selectables wrapped in WidgetSpan children of a Text.rich inside a SelectableRegion. The enclosing _SelectableTextContainer's delegate picks up the mocks through SelectionContainer.maybeOf(context), so the post-frame sort routes them through _SelectableTextContainerDelegate._compareScreenOrder (the override in text.dart) specifically. One case per error type.

A comparator-consistency test in each file additionally mixes laid-out and unlaid-out selectables (with the unlaid-out one placed between the others in the tree) and asserts the merged order is deterministic with the unlaid-out entry last.

Widget-level regression guards

Two additional testWidgets cases cover the happy path of both guarded methods so later edits don't accidentally break normal sort behavior:

  • selectable_region_test.dart — multiple sibling Text widgets under a SelectionArea inside a Navigator(pages:) where the second page covers the first.
  • text_test.dart — Text.rich with WidgetSpan children. paragraph.dart's _getSelectableFragments only splits on placeholder characters, so plain Text produces a single fragment and wouldn't drive the sort — WidgetSpan placeholders are required to get multiple fragments per container. Kept at the widgets/ layer (TestWidgetsApp + TestPage) to match the rest of text_test.dart.

Full affected suites are green locally:

Suite Result
selectable_region_test.dart + text_test.dart 287 passed, 0 failed
selection_container_test.dart + rendering/paragraph_test.dart 54 passed, 0 failed

flutter analyze --flutter-repo is clean on the full tree.

Additional evidence: dart2wasm runtime repro

The red/green unit tests prove the catch branches work in isolation. The dart2wasm runtime repro proves the guard prevents the actual production crash end-to-end:

Repro: https://github.com/davidmigloz/flutter_selection_crash — three approaches (A / B / C) that crash reliably on dart2wasm Chrome (flutter run -d chrome --wasm) with StateError: Bad state: RenderBox was not laid out thrown from _compareScreenOrder. Each approach hits a different frame in the same method. Approaches A and C land in selectable_region.dart; approach B lands in text.dart via _SelectableFragment.boundingBoxes.

Verified locally: Built the repro app against this branch and exercised all three approaches with flutter run -d chrome --wasm (dart2wasm debug). All three previously crashed, none throw the error anymore. The crash also reproduces on master in DDC debug, dart2js release, and dart2wasm release — same _compareScreenOrder code path in each case — so the guard is expected to cover them too, but only dart2wasm debug was verified by hand on this branch.

Why not a route-level SelectionContainer.disabled wrapper?

We originally explored wrapping non-current route content in SelectionContainer.disabled inside _ModalScopeState.build(). This approach is fundamentally limited: SelectionArea / SelectableRegion create their own SelectionContainer(registrar: this, ...) inside the route (selectable_region.dart:1944), which installs a fresh registrar that shadows the disabled outer scope. The route-level wrapper cannot suppress selection inside nested SelectionArea instances — exactly the case this issue is about. Independent review of four separate research passes converged on the comparator-level guard as the only viable surgical framework fix.

Known limitations

  • Dead zones (#182573): Stale selectables from offstage routes remain registered and can intercept drag events (silent variant). This PR is a crash guard at sort time — it prevents _flushAdditions from crashing when it encounters an unlaid-out selectable, but it does not remove those selectables from the delegate or fix the dead-zone behavior itself. App-level workarounds like flutter/packages#11062 (for go_router) remain needed for the behavioral fix. A proper framework fix is likely structural rather than comparator-based: the underlying problem is that selectables from obscured routes can remain registered even when _RenderTheater skips laying them out, so they continue participating in selection before their geometry is valid. Fixing that cleanly would likely require a route/overlay-driven visibility or readiness signal in the selection system itself, so hidden selectables are suppressed or unregistered until they are laid out again.
  • Other unguarded geometry reads: selectable_region.dart and text.dart have other boundingBoxes / getTransformTo(null) sites outside _compareScreenOrder (e.g., _handleSelectWordSelectionEvent around line 2969 in selectable_region.dart; text.dart at lines 1069, 1071, 1407). These are less frequently hit in the _flushAdditions post-frame path — the vast majority of production Sentry events for this issue land in _compareScreenOrder. Follow-up PRs can guard them if reported.

Related

Pre-launch Checklist

@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels Apr 10, 2026
@davidmigloz

davidmigloz commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor Author

@Renzo-Olivares this is the framework-level follow-up to flutter/packages#11062 that you reviewed. Per your feedback, I looked at moving the fix to the framework. The route-level SelectionContainer.disabled wrapper turned out to be fundamentally limited (nested SelectionArea creates its own registrar that shadows the disabled outer scope — detailed in the PR body). This PR is the surgical alternative: a try/catch guard in both _compareScreenOrder overrides.

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

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.

Code Review

This pull request addresses a crash occurring in SelectableRegion and SelectableText when attempting to sort selectables that have not yet undergone layout, such as those within obscured overlay entries. The _compareScreenOrder methods in both MultiSelectableSelectionContainerDelegate and _SelectableTextContainerDelegate now include try-catch blocks to handle StateError and AssertionError, returning a neutral sort order of 0 when these exceptions occur. Additionally, regression tests have been implemented in selectable_region_test.dart and text_test.dart to verify the fix. I have no further feedback to provide.

@davidmigloz davidmigloz changed the title Guard _compareScreenOrder against unlaid-out selectables widgets: Guard _compareScreenOrder against unlaid-out selectables Apr 10, 2026
davidmigloz added a commit to davidmigloz/flutter_selection_crash that referenced this pull request Apr 10, 2026
Updates README.md, INVESTIGATION.md, lib/main.dart, and the kept
verification doc to link flutter/flutter#184900 as the framework fix
PR and the follow-up upstream comment that retracts the earlier
_ModalScopeState.build() proposal.

Also cleans up investigation/ by deleting the AI-generated research
reports (report1/2/3.md), the validation prompt, the upstream research
dossier, and the framework line-numbers snapshot. Those were
point-in-time artifacts of the fix-direction investigation, contained
internal project-management language (phases/waves/gates), and matched
the red-flag pattern Flutter's AI contribution policy calls out for
PR reviewers. The distilled findings they supported are already
captured in INVESTIGATION.md's "Why NOT route-level
SelectionContainer.disabled" section, which is self-contained.

Only investigation/2026-04-10_verification.md is kept, trimmed of the
internal planning language. It preserves the stack traces for all
three crash approaches (A, B, C) and the opaque:false isolation test,
which are the load-bearing evidence for the PR.

The INVESTIGATION.md code sample is also updated to match the actual
landed form of the guard (uses the _compareVertically /
_compareHorizontally helpers rather than direct compareTo calls on
rect.top / rect.left), and the Status header is updated to
2026-04-11 with the PR link front and center.
@Piinks
Piinks requested a review from Renzo-Olivares April 14, 2026 22:14
@justinmc

justinmc commented May 5, 2026

Copy link
Copy Markdown
Contributor

@davidmigloz Heads up that you have a merge conflict to resolve here, thanks.

@Piinks

Piinks commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Hey @davidmigloz sorry this fell through our review cracks, there was a minute there where we were overrun with AI generated PRs. 😅 Would you like to continue working on this? There are some merge conflicts to resolve, and I can definitely make sure it gets a review.

@davidmigloz
davidmigloz force-pushed the fix-selection-non-current-route branch from 36f0728 to e47bc19 Compare July 16, 2026 12:13
@davidmigloz

davidmigloz commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @Piinks, no problem, and yes, happy to keep working on this.

Rebased onto latest master. The fix itself didn't conflict; the conflicts were in selectable_region_test.dart, which migrated to the widgets-only test harness in the meantime, so I ported the new tests to TestWidgetsApp/TestPage.

Should be ready for review.

When _RenderTheater skips layout for an obscured OverlayEntry (e.g.,
a non-current route behind an opaque route), selectables in that entry
remain registered with their SelectionContainerDelegate. When
_flushAdditions runs the sort via _compareScreenOrder, it accesses
paintBounds/getTransformTo on RenderBoxes that have no size, throwing
StateError in release mode and AssertionError in debug mode.

This change wraps the geometry access in _compareScreenOrder with a
try/catch that returns 0 (equal ordering) for unlaid-out selectables.
Both _compareScreenOrder methods are guarded:
- MultiSelectableSelectionContainerDelegate (selectable_region.dart)
- _SelectableTextContainerDelegate (text.dart)

They will be properly positioned on the next frame when layout
completes.

Fixes flutter#151536
The regression tests added in the previous commit only exercise the
happy path of _compareScreenOrder (the try block). Add mock-based
red/green tests that directly exercise the on StateError and on
AssertionError catch branches by registering Selectables whose
boundingBoxes getter throws.

Verified that both tests fail without the guard (the thrown errors
propagate out of _flushAdditions' post-frame callback and
tester.takeException() reports them) and pass with the guard.

Related to flutter#151536
The previous commit's red/green tests only exercised the
MultiSelectableSelectionContainerDelegate._compareScreenOrder override
in selectable_region.dart. Add the equivalent red/green tests for
_SelectableTextContainerDelegate._compareScreenOrder in text.dart by
registering throwing mock Selectables via WidgetSpan children of a
Text.rich wrapped in SelectableRegion.

The enclosing _SelectableTextContainer's delegate picks the mocks up
through SelectionContainer.maybeOf(context), so the post-frame sort
routes them through text.dart's comparator override specifically.

Verified red/green: both tests fail without the text.dart guard (the
thrown StateError / AssertionError propagates out of _flushAdditions'
post-frame callback and tester.takeException() reports it) and pass
with the guard.

Related to flutter#151536
The previous fix caught StateError/AssertionError around the entire
_compareScreenOrder body and returned 0 for either error, making an
unlaid-out selectable compare equal to every laid-out one while
laid-out ones still compare non-zero among themselves. That violates
the comparator contract List.sort relies on and can corrupt ordering.

Narrow the catch to only the geometry read (via a nullable
_getScreenRect helper) and use its null result to sort unlaid-out
selectables after laid-out ones, tying only among themselves. This is
a consistent total preorder; ordering self-corrects the next time
selectables are flushed/re-sorted.

Adds a regression test per file mixing laid-out and unlaid-out
selectables to assert the sort is deterministic and unavailable
entries land last.
@davidmigloz
davidmigloz force-pushed the fix-selection-non-current-route branch from 98bf19f to 51537a2 Compare July 22, 2026 21:21
@Renzo-Olivares

Copy link
Copy Markdown
Contributor

@davidmigloz thank you for the patience on this one. And apologies for letting it fall through the cracks. I'll be out of office after today but will take a look next week. This is definitely an issue we want to see fixed.

@Renzo-Olivares Renzo-Olivares left a comment

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.

Hi @davidmigloz, thank you for the patience on this on this one. I don't think the comparator guard is the right shape to land for the target issue. It reacts to the crash rather than fixing the source of the crash, it also does not fix the sibling problem of dead-zones.

I think to really take into account all the edge cases this issue targets and discuss them thoroughly you should write a small design doc. I still think the long-term fix is partially still at the routing level despite SelectionContainer.disabled not being able to penetrate nested SelectionArea's. SelectionContainer.disabled not being able to penetrate nested SelectionArea's sounds like a gap we should discuss as well.

// debugNeedsLayout) throw AssertionError first.
// See https://github.com/flutter/flutter/issues/151536
static Rect? _getScreenRect(Selectable selectable) {
try {

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 think this fix is reactive instead of proactive, i.e. we are waiting for the crash to happen rather than structurally fixing what caused the crash to begin with. Ideally we would have some way for the Selectable to signal it's geometry is not available, or have it not attempt to register itself at all if it is not in the current route/not laid out.

@Renzo-Olivares Renzo-Olivares added the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Aug 3, 2026
@davidmigloz

davidmigloz commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@Renzo-Olivares thanks for the review. The guard was indeed meant as a band aid to stop the crashes while a proper structural solution gets discussed, not as the long term shape. I have quite some context on this by now, so happy to put together the design doc: I'll cover both issues and the edge cases (including the SelectionContainer.disabled vs nested SelectionArea gap we ran into), prototype the candidate approaches, and share the link with the team.

Converting this PR to draft in the meantime, the regression tests should stay reusable whichever shape we land on.

@davidmigloz
davidmigloz marked this pull request as draft August 16, 2026 22:09
@github-actions github-actions Bot removed the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Aug 16, 2026
@Renzo-Olivares

Copy link
Copy Markdown
Contributor

Sounds good to me @davidmigloz, thank you for taking this on!

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 framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants