Skip to content

SelectableText crashes the app if custom contextMenuBuilder is used, Null check operator used on a null value #155514

Closed

Description

@absar

Steps to reproduce

  1. Run the code sample
  2. Press the button, it will open an overlay
  3. Tap the text, it will crash

It happens on both Flutter 3.24.3 and 3.22.3. Works fine on 3.19.6

Expected results

Should show the custom context menu instead of crashing

Actual results

Crashes the app

Code sample

Code sample
import 'package:flutter/material.dart';

void main() => runApp(const CustomContextMenuBuilderIssue());

class CustomContextMenuBuilderIssue extends StatefulWidget {
  const CustomContextMenuBuilderIssue({super.key});

  @override
  State<CustomContextMenuBuilderIssue> createState() => _CustomContextMenuBuilderIssueState();
}

class _CustomContextMenuBuilderIssueState extends State<CustomContextMenuBuilderIssue> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Custom contextMenuBuilder')),
        body: const Center(child: TheButton()),
      ),
    );
  }
}

class TheButton extends StatefulWidget {
  const TheButton({super.key});

  @override
  State<TheButton> createState() => _TheButtonState();
}

class _TheButtonState extends State<TheButton> with TickerProviderStateMixin {
  late final _animationController =
      AnimationController(duration: const Duration(milliseconds: 300), vsync: this);
  bool _openInProgress = false;
  OverlayEntry? _popup;
  late ThemeData _theme;
  String _value = '0.0';

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _theme = Theme.of(context);
  }

  @override
  Widget build(BuildContext context) {
    _value = 'Tap me to crash';
    return FilledButton.tonal(onPressed: _showPopup, child: const Text('Press me'));
  }

  void _showPopup() async {
    if (_openInProgress) return;
    _openInProgress = true;
    _popup ??= OverlayEntry(builder: _popupBuilder);
    Overlay.of(context).insert(_popup!);
    await _animationController.forward(from: 0);
    setState(() {});
  }

  Widget _popupBuilder(BuildContext context) {
    return PositionedDirectional(
      bottom: 0,
      start: 0,
      end: 0,
      child: SizedBox(
        height: 322.0,
        child: Material(
          color: _theme.colorScheme.secondaryContainer,
          type: MaterialType.card,
          child: ThePopUp(initialValue: _value),
        ),
      ),
    );
  }
}

typedef PopupOnChanged = void Function(double? amount);

class ThePopUp extends StatefulWidget {
  const ThePopUp({super.key, required this.initialValue});

  final String initialValue;

  @override
  State<ThePopUp> createState() => _ThePopUpState();
}

class _ThePopUpState extends State<ThePopUp> {
  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints(maxWidth: 400),
      child: ValueDisplay(value: widget.initialValue),
    );
  }
}

class ValueDisplay extends StatelessWidget {
  const ValueDisplay({super.key, required this.value});

  final String? value;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SizedBox(
      height: 40,
      child: FittedBox(
        fit: BoxFit.scaleDown,
        child: SelectableText(
          value.toString(),
          textAlign: TextAlign.center,
          maxLines: 1,
          style: theme.textTheme.titleLarge,
          contextMenuBuilder: _contextMenuBuilder,
        ),
      ),
    );
  }

  void _pasteFromClipboard() async {
    ContextMenuController.removeAny();
    print('Fake paste');
  }

  Widget _contextMenuBuilder(BuildContext context, EditableTextState state) {
    final List<ContextMenuButtonItem> buttonItems = state.contextMenuButtonItems;
    final int pasteButtonIndex =
        buttonItems.indexWhere((buttonItem) => buttonItem.type == ContextMenuButtonType.paste);

    if (pasteButtonIndex >= 0) {
      final pasteButton = buttonItems[pasteButtonIndex];
      buttonItems[pasteButtonIndex] = pasteButton.copyWith(onPressed: _pasteFromClipboard);
    } else {
      final pasteButton = ContextMenuButtonItem(
        onPressed: _pasteFromClipboard,
        type: ContextMenuButtonType.paste,
      );
      if (buttonItems.length > 1) {
        buttonItems[1] = pasteButton;
      } else {
        buttonItems.add(pasteButton);
      }
    }

    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: state.contextMenuAnchors,
      buttonItems: buttonItems,
    );
  }
}

Screenshots or Video

No response

Logs

Logs
======== Exception caught by widgets library =======================================================
The following _TypeError was thrown building Overlay-[LabeledGlobalKey<OverlayState>#de574](state: OverlayState#b0685(entries: [OverlayEntry#809eb(opaque: true; maintainState: false), OverlayEntry#de194(opaque: false; maintainState: true), OverlayEntry#e7ffa(opaque: false; maintainState: false)])):
Null check operator used on a null value

The relevant error-causing widget was: 
  MaterialApp MaterialApp:file:///issue_reproducibles/lib/flutter/custom_context_menu_builder_issue.dart:15:12
When the exception was thrown, this was the stack: 
#0      _OverlayEntryWidgetState.initState (package:flutter/src/widgets/overlay.dart:364:44)
#1      StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5748:55)
#2      ComponentElement.mount (package:flutter/src/widgets/framework.dart:5593:5)
#3      Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
#4      MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7035:36)
#5      Element.updateChild (package:flutter/src/widgets/framework.dart:3963:18)
#6      Element.updateChildren (package:flutter/src/widgets/framework.dart:4150:32)
#7      MultiChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:7060:17)
#8      Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#9      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5642:16)
#10     StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5780:11)
#11     Element.rebuild (package:flutter/src/widgets/framework.dart:5333:7)
#12     BuildScope._tryRebuild (package:flutter/src/widgets/framework.dart:2693:15)
#13     BuildScope._flushDirtyElements (package:flutter/src/widgets/framework.dart:2752:11)
#14     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3048:18)
#15     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1162:21)
#16     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:468:5)
#17     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1397:15)
#18     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1318:9)
#19     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1176:5)
#20     _invoke (dart:ui/hooks.dart:312:13)
#21     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:419:5)
#22     _drawFrame (dart:ui/hooks.dart:283:31)
====================================================================================================

Flutter Doctor output

Doctor output
[√] Flutter (Channel stable, 3.24.3, on Microsoft Windows [Version 10.0.22631.4169], locale en-US)
    • Flutter version 3.24.3 on channel stable at D:\Apps\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 2663184aa7 (11 days ago), 2024-09-11 16:27:48 -0500
    • Engine revision 36335019a8
    • Dart version 3.5.3
    • DevTools version 2.37.3

Activity

  1. danagbemava-nc commented on Sep 23, 2024

    @danagbemava-nc
    Member

    Reproducible using the code sample and steps provided above. I can confirm that the error message does appear in 3.22.3+ but not in 3.19.6

    flutter doctor -v
    [✓] Flutter (Channel stable, 3.24.3, on macOS 15.0 24A335 darwin-arm64, locale en-US)
        • Flutter version 3.24.3 on channel stable at /Users/deanli/dev/stable
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 2663184aa7 (12 days ago), 2024-09-11 16:27:48 -0500
        • Engine revision 36335019a8
        • Dart version 3.5.3
        • DevTools version 2.37.3
    
    [!] Flutter (Channel master, 3.26.0-1.0.pre.173, on macOS 15.0 24A335 darwin-arm64, locale en-US)
        • Flutter version 3.26.0-1.0.pre.173 on channel master at /Users/deanli/dev/master
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 101c8ae6c2 (16 hours ago), 2024-09-22 14:21:32 -0400
        • Engine revision 0155f8fc45
        • Dart version 3.6.0 (build 3.6.0-269.0.dev)
        • DevTools version 2.40.0-dev.1
    
    
  2. added
    frameworkflutter/packages/flutter repository. See also f: labels.
    a: error messageError messages from the Flutter framework
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    f: selectionSelectableRegion, SelectionArea, SelectionContainer, Selectable, and related APIs
    c: regressionIt was better in the past than it is now
    and removed
    in triagePresently being triaged by the triage team
    on Sep 23, 2024
  3. TahaTesser commented on Sep 24, 2024

    @TahaTesser
    Contributor

    This is caused by #142463 cc: @justinmc

    Here i created more simpler sample. This only reproduces in an Overlay.

    Code sample

    expand to view the code sample
    import 'package:flutter/material.dart';
    
    /// Flutter code sample for [Overlay].
    
    void main() => runApp(const OverlayApp());
    
    class OverlayApp extends StatelessWidget {
      const OverlayApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          home: OverlayExample(),
        );
      }
    }
    
    class OverlayExample extends StatefulWidget {
      const OverlayExample({super.key});
    
      @override
      State<OverlayExample> createState() => _OverlayExampleState();
    }
    
    class _OverlayExampleState extends State<OverlayExample> {
      OverlayEntry? overlayEntry;
    
      void createHighlightOverlay() {
        // Remove the existing OverlayEntry.
        removeHighlightOverlay();
    
        assert(overlayEntry == null);
    
        overlayEntry = OverlayEntry(
          builder: (BuildContext context) {
            return Center(
              child: DefaultTextStyle(
                style: const TextStyle(color: Colors.red, fontSize: 22),
                child: SelectableText(
                  'Hello, World!',
                  contextMenuBuilder:
                      (BuildContext context, EditableTextState state) {
                    return AdaptiveTextSelectionToolbar.buttonItems(
                      anchors: state.contextMenuAnchors,
                      buttonItems: state.contextMenuButtonItems,
                    );
                  },
                ),
              ),
            );
          },
        );
    
        // Add the OverlayEntry to the Overlay.
        Overlay.of(context, debugRequiredFor: widget).insert(overlayEntry!);
      }
    
      // Remove the OverlayEntry.
      void removeHighlightOverlay() {
        overlayEntry?.remove();
        overlayEntry?.dispose();
        overlayEntry = null;
      }
    
      @override
      void dispose() {
        // Make sure to remove OverlayEntry when the widget is disposed.
        removeHighlightOverlay();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Overlay Sample'),
          ),
          body: Align(
            alignment: Alignment.topCenter,
            child: ElevatedButton(
              onPressed: () {
                createHighlightOverlay();
              },
              child: const Text('Show Overlay'),
            ),
          ),
        );
      }
    }
    
  4. added and removed on Sep 25, 2024
  5. justinmc commented on Sep 27, 2024

    @justinmc
    Contributor

    Thank you for the analysis @TahaTesser! It's reassuring that this only happens in an Overlay at least. I want to look at this but likely won't get a chance for a week or two. I will assign myself if I'm working on it.

  6. added
    P2Important issues not at the top of the work list
    on Oct 3, 2024
  7. self-assigned this
    on Oct 16, 2024
  8. justinmc commented on Oct 16, 2024

    @justinmc
    Contributor

    You can workaround this by defining your custom contextMenuBuilder in a separate method rather than an inline lambda. That ensures that it isn't redefined on each build and it passes an equality check with itself. Here is @TahaTesser's code from above modified with the workaround:

    Example code with workaround
    import 'package:flutter/material.dart';
    
    /// Flutter code sample for [Overlay].
    
    void main() => runApp(const OverlayApp());
    
    class OverlayApp extends StatelessWidget {
      const OverlayApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          home: OverlayExample(),
        );
      }
    }
    
    class OverlayExample extends StatefulWidget {
      const OverlayExample({super.key});
    
      @override
      State<OverlayExample> createState() => _OverlayExampleState();
    }
    
    class _OverlayExampleState extends State<OverlayExample> {
      OverlayEntry? overlayEntry;
    
      // Here I've put the contextMenuBuilder in a separate method so that it is not
      // redefined on every build and it passes an equality check with itself.
      static Widget _contextMenuBuilder(BuildContext context, EditableTextState state) {
        return AdaptiveTextSelectionToolbar.editableText(
          editableTextState: state,
        );
      }
    
      void createHighlightOverlay() {
        // Remove the existing OverlayEntry.
        removeHighlightOverlay();
    
        assert(overlayEntry == null);
    
        overlayEntry = OverlayEntry(
          builder: (BuildContext context) {
            return const Center(
              child: DefaultTextStyle(
                style: TextStyle(color: Colors.red, fontSize: 22),
                child: SelectableText(
                  'Hello, World!',
                  contextMenuBuilder: _contextMenuBuilder,
                ),
              ),
            );
          },
        );
    
        // Add the OverlayEntry to the Overlay.
        Overlay.of(context, debugRequiredFor: widget).insert(overlayEntry!);
      }
    
      // Remove the OverlayEntry.
      void removeHighlightOverlay() {
        overlayEntry?.remove();
        overlayEntry?.dispose();
        overlayEntry = null;
      }
    
      @override
      void dispose() {
        // Make sure to remove OverlayEntry when the widget is disposed.
        removeHighlightOverlay();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Overlay Sample'),
          ),
          body: Align(
            alignment: Alignment.topCenter,
            child: ElevatedButton(
              onPressed: () {
                createHighlightOverlay();
              },
              child: const Text('Show Overlay'),
            ),
          ),
        );
      }
    }
    Diff
    27a28,35
    >   // Here I've put the contextMenuBuilder in a separate method so that it is not
    >   // redefined on every build and it passes an equality check with itself.
    >   static Widget _contextMenuBuilder(BuildContext context, EditableTextState state) {
    >     return AdaptiveTextSelectionToolbar.editableText(
    >       editableTextState: state,
    >     );
    >   }
    > 
    36c44
    <         return Center(
    ---
    >         return const Center(
    38c46
    <             style: const TextStyle(color: Colors.red, fontSize: 22),
    ---
    >             style: TextStyle(color: Colors.red, fontSize: 22),
    41,45c49
    <               contextMenuBuilder: (BuildContext context, EditableTextState state) {
    <                 return AdaptiveTextSelectionToolbar.editableText(
    <                   editableTextState: state,
    <                 );
    <               },
    ---
    >               contextMenuBuilder: _contextMenuBuilder,
    87a92
    > 
  9. justinmc commented on Oct 16, 2024

    @justinmc
    Contributor

    I think the root cause here is the fact that TextSelectionOverlay was never meant to have its values updated, and so we simply dispose and recreate it whenever they do:

    _selectionOverlay!.dispose();
    _selectionOverlay = _createSelectionOverlay();

    In this case, the OverlayEntries are built and then immediately disposed, and when initState is called on their _OverlayEntryWidgetStates, their OverlayEntry has already been disposed, causing the error Null check operator used on a null value.

    Really, we should come up with a way to update these values that doesn't involved disposing and recreating the overlay.

    CC @LongCatIsLooong in case you happen to have any ideas or think that there is something to fix in Overlay. Otherwise I think everyone should use the workaround in the meantime until we can do a bigger refactor of TextSelectionOverlay.

  10. added
    P3Issues that are less important to the Flutter project
    on Oct 16, 2024
  11. removed their assignment
    on Oct 16, 2024
  12. removed
    P3Issues that are less important to the Flutter project
    on Oct 16, 2024
  13. github-actions commented on May 23, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  14. locked as resolved and limited conversation to collaborators on May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: error messageError messages from the Flutter frameworkc: regressionIt was better in the past than it is nowf: selectionSelectableRegion, SelectionArea, SelectionContainer, Selectable, and related APIsfound in release: 3.24Found to occur in 3.24found in release: 3.26Found to occur in 3.26frameworkflutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onteam-text-inputOwned by Text Input teamtriaged-text-inputTriaged by Text Input teamworkaround availableThere is a workaround available to overcome the issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions