Repository navigation
widgets: Guard _compareScreenOrder against unlaid-out selectables - #184900
davidmigloz wants to merge 5 commits into
Conversation
|
@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 |
There was a problem hiding this comment.
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.
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.
|
@davidmigloz Heads up that you have a merge conflict to resolve here, thanks. |
|
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. |
36f0728 to
e47bc19
Compare
|
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 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.
98bf19f to
51537a2
Compare
|
@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
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 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. |
|
Sounds good to me @davidmigloz, thank you for taking this on! |
Relates to #151536
Description
When
_RenderTheaterskips layout for an obscuredOverlayEntry(e.g., a non-current route behind an opaque route), selectables in that entry remain registered with theirSelectionContainerDelegate. When_flushAdditionsruns the merge-sort via_compareScreenOrder, it accessespaintBounds/getTransformTo/boundingBoxes.firstonRenderBoxes that have no size, throwingStateErrorin release mode andAssertionErrorin debug.This change makes geometry-acquisition failure explicit instead of crashing the sort: each
_compareScreenOrdernow reads geometry through a smallRect?helper whoseon StateError/on AssertionErrorcatch is scoped to just that read. The comparator handlesnullexplicitly — 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 returned0whenever either side lacked geometry; that tied an unlaid-out selectable with every laid-out one — an inconsistent order thatList.sortand the merge logic aren't guaranteed to handle.)Two methods are guarded because
_flushAdditionscan end up calling either override:MultiSelectableSelectionContainerDelegate._compareScreenOrderinpackages/flutter/lib/src/widgets/selectable_region.dart— used by top-levelSelectionArea/SelectableRegion._SelectableTextContainerDelegate._compareScreenOrderinpackages/flutter/lib/src/widgets/text.dart— used when a singleText/Text.richhas multiple_SelectableFragments (e.g., text withWidgetSpans).Why catch
AssertionErrortooRenderBox.sizeevaluates_size ?? (throw StateError(...)). In release mode theStateErrorfires. In debug modeassert(hasSize)fires first and throwsAssertionErrorbefore control ever reaches thethrow. Catching onlyStateErrorwould leave debug builds crashing while release silently recovers — the exact opposite of what we want. Theon AssertionErrorclause keeps the guard symmetric across build modes.Note:
avoid_catching_errorsis currently commented out inanalysis_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
testWidgetscases directly exercise theon StateErrorandon AssertionErrorcatch branches of both guarded methods by registering mockSelectables whoseboundingBoxesgetter throws. All four are proper red/green tests: they fail onmaster(the thrown error propagates out of_flushAdditions' post-frame callback andtester.takeException()reports it) and pass with this change (the helper returnsnull, unavailable selectables sort after available ones, and the sort completes).packages/flutter/test/widgets/selectable_region_test.dart— mockSelectables under a top-levelSelectableRegionroute throughMultiSelectableSelectionContainerDelegate._compareScreenOrder(the override inselectable_region.dart). One case per error type.packages/flutter/test/widgets/text_test.dart— mockSelectables wrapped inWidgetSpanchildren of aText.richinside aSelectableRegion. The enclosing_SelectableTextContainer's delegate picks up the mocks throughSelectionContainer.maybeOf(context), so the post-frame sort routes them through_SelectableTextContainerDelegate._compareScreenOrder(the override intext.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
testWidgetscases cover the happy path of both guarded methods so later edits don't accidentally break normal sort behavior:selectable_region_test.dart— multiple siblingTextwidgets under aSelectionAreainside aNavigator(pages:)where the second page covers the first.text_test.dart—Text.richwithWidgetSpanchildren.paragraph.dart's_getSelectableFragmentsonly splits on placeholder characters, so plainTextproduces a single fragment and wouldn't drive the sort —WidgetSpanplaceholders are required to get multiple fragments per container. Kept at thewidgets/layer (TestWidgetsApp+TestPage) to match the rest oftext_test.dart.Full affected suites are green locally:
selectable_region_test.dart+text_test.dartselection_container_test.dart+rendering/paragraph_test.dartflutter analyze --flutter-repois 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) withStateError: Bad state: RenderBox was not laid outthrown from_compareScreenOrder. Each approach hits a different frame in the same method. Approaches A and C land inselectable_region.dart; approach B lands intext.dartvia_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_compareScreenOrdercode 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.disabledwrapper?We originally explored wrapping non-current route content in
SelectionContainer.disabledinside_ModalScopeState.build(). This approach is fundamentally limited:SelectionArea/SelectableRegioncreate their ownSelectionContainer(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 nestedSelectionAreainstances — 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
_flushAdditionsfrom 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 (forgo_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_RenderTheaterskips 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.selectable_region.dartandtext.darthave otherboundingBoxes/getTransformTo(null)sites outside_compareScreenOrder(e.g.,_handleSelectWordSelectionEventaround line 2969 inselectable_region.dart;text.dartat lines 1069, 1071, 1407). These are less frequently hit in the_flushAdditionspost-frame path — the vast majority of production Sentry events for this issue land in_compareScreenOrder. Follow-up PRs can guard them if reported.Related
go_router-level workaround, reviewed by @Renzo-OlivaresPre-launch Checklist
///).