Fix SelectableText crash with inline lambda contextMenuBuilder#184990
Conversation
There was a problem hiding this comment.
Code Review
This pull request enables in-place updates for context menus by making the contextMenuBuilder in TextSelectionOverlay mutable and modifying ContextMenuController to update existing overlays instead of recreating them. This change prevents crashes that occur when the menu is disposed of and rebuilt during the build phase. Review feedback suggests improving the robustness of the OverlayEntry builder by avoiding unsafe null-assertions on the static instance and ensuring that the toolbar is explicitly hidden if the context menu builder is removed during an update.
| _menuOverlayEntry = OverlayEntry( | ||
| builder: (BuildContext context) { | ||
| return capturedThemes.wrap(contextMenuBuilder(context)); | ||
| return _shownInstance!._capturedThemes!.wrap(_shownInstance!._contextMenuBuilder!(context)); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
Accessing the static _shownInstance with the null-assertion operator (!) inside the OverlayEntry builder is potentially unsafe. If the controller is removed or the state becomes inconsistent during a rebuild cycle, this could lead to a runtime crash. It is safer to capture the instance in a local variable and provide a fallback widget (like SizedBox.shrink()) if the instance or its builder is null.
_menuOverlayEntry = OverlayEntry(
builder: (BuildContext context) {
final ContextMenuController? instance = _shownInstance;
if (instance == null || instance._contextMenuBuilder == null) {
return const SizedBox.shrink();
}
return instance._capturedThemes!.wrap(instance._contextMenuBuilder!(context));
},
);There was a problem hiding this comment.
This seems reasonable... It can be reset to null in removeAny. Is there another reason why it will never be null?
There was a problem hiding this comment.
If the _shownInstance should never be null in the builder then I think we should probably leave an assert.
There was a problem hiding this comment.
_showInstance is never null because before inserting the _menuOverlayEntry (which will call OverlayEntry.builder) into the Overlay it is set to this:
_shownInstance = this;
I added an assert for posterity though.
| if (_selectionOverlay!.toolbarIsVisible) { | ||
| // We use addPostFrameCallback to avoid calling markNeedsBuild() during | ||
| // the build phase. This delays the update to the next frame, which could | ||
| // theoretically cause a 1-frame lag if the content changes drastically. | ||
| // However, for text selection menus, this is negligible. | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (mounted) { | ||
| _selectionOverlay!.showToolbar(); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
When the contextMenuBuilder is updated to null (or disabled on web), the current implementation calls showToolbar() in a post-frame callback. However, showToolbar() typically returns early if the builder is null, which means an existing toolbar will not be dismissed. If the builder becomes null, hideToolbar() should be called instead to ensure the menu is removed from the UI.
if (_selectionOverlay!.toolbarIsVisible) {
if (_selectionOverlay!.contextMenuBuilder == null) {
_selectionOverlay!.hideToolbar();
} else {
// We use addPostFrameCallback to avoid calling markNeedsBuild() during
// the build phase. This delays the update to the next frame, which could
// theoretically cause a 1-frame lag if the content changes drastically.
// However, for text selection menus, this is negligible.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_selectionOverlay!.showToolbar();
}
});
}
}There was a problem hiding this comment.
I think what Gemini is talking about is a new behavior: it says that setting contextMenuBuilder to null should hide the menu, but I don't think that happened before this PR. Maybe Gemini is right, but that's probably not for this PR if I'm understanding correctly.
…sanni/flutter into selectable-text-crash-fix
| // tree, and didUpdateWidget is called before layout. | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (mounted && (_selectionOverlay?.toolbarIsVisible ?? false)) { | ||
| _selectionOverlay!.showToolbar(); |
There was a problem hiding this comment.
Is this the only way we can tell the toolbar to rebuild (there's no request update API or some such)?
There was a problem hiding this comment.
Not that I can find, maybe SelectionOverlay.markNeedsBuild? But this is the convention.
|
auto label is removed for flutter/flutter/184990, Failed to enqueue flutter/flutter/184990 with HTTP 400: Pull request Required status check "Check Code Freeze" is expected.. |
Instead of disposing and recreating the
OverlayEntrywhencontextMenuBuilderchanges (which triggered the crash during the build phase), we now update thecontextMenuBuilderin place and trigger a rebuild of the existing overlay entry (by callingmarkNeedsBuildinContextMenuController.show).To avoid calling
markNeedsBuildduring the build phase (which is not allowed), we defer the call toshowToolbar()inEditableTextState.didUpdateWidgetusingaddPostFrameCallback.Fixes SelectableText crashes the app if custom contextMenuBuilder is used, Null check operator used on a null value