Skip to content

[widgets/overlay_portal.dart] OverlayPortal semantics tree crashes or behaves unexpectedly on MacOS. #187198

Open

Description

@davidhicks980

Steps to reproduce

I'm having trouble getting a consistent replication. I'll share two videos to demonstrate. This bug seems to be related to traversalParentIdentifier, and may be related to #182604. I'm not sure if this is relevant, but I'm running on macOS 15.5

  1. Run example on macOS
  2. Turn on VoiceOver
  3. Open the menu

Expected results

Accessibility focus moves to overlay and buttons and shows the correct semantic boxes around overlay items.

Actual results

Either the AXTree completely crashes, or the semantics rectangles are not properly positioned. Yesterday, the semantics tree always crashed. I'm not sure why it isn't crashing today.

Also, the semantics debugger is not showing the overlay tree. This is due to:

if (node.traversalChildIdentifier != null) {
return;
}

Removing this line shows the correct semantics, so I'm not sure what the issue was:

Image

@chunhtai @QuncCccccc

Code sample

Code sample

First video code snippet:

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      showSemanticsDebugger: true
      home: Bug()
    )
  );
}

class Bug extends StatelessWidget {
  const Bug({super.key});

  @override
  Widget build(BuildContext context) {
    final controller = OverlayPortalController();
    return Scaffold(
      body: SizedBox(
        width: 50,
        height: 50,
        child: OverlayPortal(
          controller: controller,
          // The massive overlay child is forced to be a logical child of the tiny 50x50 box.
          // This destroys the globalRect matrix and crashes ui::AXTree.
          overlayChildBuilder: (context) {
            return Positioned(
              top: 50,
              left: 50,
              child: Semantics(
                label: 'I will not show in the debugger and crash AXTree',
                child: Container(width: 300, height: 300, color: Colors.red),
              ),
            );
          },
          child: TextButton(onPressed: controller.toggle, child: const Text('Open')),
        ),
      ),
    );
  }
}

Second video code snippet:

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

/// A Simple Cascading Menu example using the [MenuAnchor] Widget.
class MyCascadingMenu extends StatefulWidget {
  const MyCascadingMenu({super.key});

  @override
  State<MyCascadingMenu> createState() => _MyCascadingMenuState();
}

class _MyCascadingMenuState extends State<MyCascadingMenu> {
  final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button');

  @override
  void dispose() {
    _buttonFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MenuAnchor(
      childFocusNode: _buttonFocusNode,
      menuChildren: <Widget>[
        MenuItemButton(onPressed: () {}, child: const Text('Revert')),
        MenuItemButton(onPressed: () {}, child: const Text('Setting')),
        MenuItemButton(onPressed: () {}, child: const Text('Send Feedback')),
      ],
      builder: (_, MenuController controller, Widget? child) {
        return IconButton(
          focusNode: _buttonFocusNode,
          onPressed: () {
            if (controller.isOpen) {
              controller.close();
            } else {
              controller.open();
            }
          },
          icon: const Icon(Icons.more_vert),
        );
      },
    );
  }
}

/// Top Level Application Widget.
class SimpleCascadingMenuApp extends StatelessWidget {
  const SimpleCascadingMenuApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('MenuAnchor Simple Example'),
          leading: const MyCascadingMenu(),
        ),
      ),
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

Video 1:

Screen.Recording.2026-05-27.at.3.55.33.PM.mov

Video 2:

Screen.Recording.2026-05-27.at.4.06.16.PM.mov

Logs

The only relevant log is the AXTree drop. I experienced this on every menu yesterday, regardless of whether the semantics debugger was enabled. I'm having trouble replicating it on every run today.

Logs
[ERROR: flutter/shell/platform/common/accessibility_bridge.cc(114)] Failed to update ui::AXTree, error: 4 will not be in the tree and is not the new root

Flutter Doctor output

Doctor output
[✓] Flutter (Channel master, 3.45.0-1.0.pre-201, on macOS 15.5 24F74 darwin-arm64, locale en-US) [1,681ms]
    • Flutter version 3.45.0-1.0.pre-201 on channel master at /Users/davidhicks/fvm/versions/master
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 40bfc7cac5 (2 days ago), 2026-05-25 11:59:40 -0400
    • Engine revision 40bfc7cac5
    • Dart version 3.13.0 (build 3.13.0-134.0.dev)
    • DevTools version 2.58.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
      enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration, enable-riscv64

[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [2.3s]
    • Android SDK at /Users/davidhicks/Library/Android/sdk
    • Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
    • Platform android-36.1, build-tools 36.1.0
    • ANDROID_HOME = /Users/davidhicks/Library/Android/sdk
    • Java binary at: /Users/davidhicks/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home/bin/java
      This JDK is specified in your Flutter configuration.
      To change the current JDK, run: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment JBR-17.0.14+1-1367.22-nomod (build 17.0.14+1-b1367.22)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 16.4) [1,612ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 16F6
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [6ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (2 available) [6.8s]
    • macOS (desktop) • macos  • darwin-arm64   • macOS 15.5 24F74 darwin-arm64
    • Chrome (web)    • chrome • web-javascript • Google Chrome 148.0.7778.179
    ! Error: Browsing on the local area network for David’s iPhone. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)

[✓] Network resources [257ms]
    • All expected network resources are available.

• No issues found!

Activity

  1. changed the title [-][widgets/overlay_portal.dart] Strange OverlayPortal behavior on MacOS.[/-] [+][widgets/overlay_portal.dart] OverlayPortal semantics tree crashes or behaves unexpectedly on MacOS.[/+] on May 27, 2026
  2. QuncCccccc commented on May 28, 2026

    @QuncCccccc
    Contributor

    Seems like this is only on macOS. Android and iOS can traverse the items correctly. Maybe the transform is calculated incorrectly on MacOS engine side?

    Image

    I actually cannot recall the details why I added the code. I vaguely remember removing this might cause failures in g3.

    if (node.traversalChildIdentifier != null) {
    return;
    }

  3. davidhicks980 commented on May 28, 2026

    @davidhicks980
    ContributorAuthor

    @QuncCccccc I think you're right. I'm not familiar enough with the engine to determine what is causing it, but from what I could gather macOS may be clipping the overlay's semantic bounds to the child's bounds because the overlay is the semantic child? Not sure.

  4. puneetkukreja98 commented on May 28, 2026

    @puneetkukreja98
    Contributor

    @davidhicks980 Thanks for reporting the issue.

    Observations:

    First video code snippet

    When showSemanticsDebugger is set to true and VoiceOver is enabled, clicking the button throws the following error:

    [ERROR:flutter/shell/platform/common/accessibility_bridge.cc(114)] Failed to update ui::AXTree, error: 4 will not be in the tree and is not the new root
    

    The error is not occurring consistently. I was able to reproduce it after trying 2–3 times while checking the logs.

    Also, Ctrl + Option + Space is not working in this scenario.

    When showSemanticsDebugger is set to false, the red container renders correctly while VoiceOver is enabled.

    Second video code snippet

    When showSemanticsDebugger is set to false and VoiceOver is enabled, Ctrl + Option + Space is working.

    However, using Ctrl + Option + Right Arrow / Left Arrow does not move focus to the menu item buttons (Revert, Settings, Send Feedback), even though VoiceOver is announcing them, as shown in above video.

    When showSemanticsDebugger is set to true, both button click and Ctrl + Option + Space are not working.

    Routing this to team-accessibility for further investigation.

  5. added
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    fyi-macosFor the attention of macOS platform team
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    and removed
    in triagePresently being triaged by the triage team
    on May 28, 2026
  6. added
    P2Important issues not at the top of the work list
    on Jun 10, 2026
  7. 3 remaining items

  8. QuncCccccc commented on Jun 22, 2026

    @QuncCccccc
    Contributor

    Additional repro signal — Flutter 3.44.0 stable, iOS (no VoiceOver)

    Can you try whether the same issue still happens on master? I landed a PR #186826 about 3 weeks ago but seems it is not in 3.44.0.

  9. NL-CanArslan commented on Jul 6, 2026

    @NL-CanArslan

    Quick follow-up from our side, we hit this again on 3.44.2 stable with a different and much easier repro path.

    We have an OverlayPortal used as an anchored popup: CompositedTransformTarget on the trigger, and the overlayChildBuilder returns a full screen dismiss barrier plus a CompositedTransformFollower panel. If you spam tap the trigger so the portal gets shown and hidden repeatedly in a short time, the semantics tree gets corrupted and every frame after that logs this cascade until the screen is rebuilt:

    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3008 pos 16: '!child.attached': is not true.
    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3246 pos 12: '_owner == null': is not true.
    'package:flutter/src/rendering/object.dart': Failed assertion: line 5713 pos 12: 'node.built': is not true.
    

    Still no accessibility service involved, semantics is enabled by Sentry's user interaction tracing like I mentioned above.

    Two data points that might help narrow it down:

    1. Wrapping the entire overlayChildBuilder content in ExcludeSemantics changes nothing, the exact same asserts keep firing. So the corruption does not seem to come from the nodes the overlay child produces, it looks like it is in the graft bookkeeping itself.
    2. Swapping OverlayPortal for a plain OverlayEntry (same barrier, same CompositedTransformFollower, everything else identical) makes the problem disappear completely. We cannot repro anymore no matter how fast we tap. That is what we shipped as a workaround for now.

    I have not been able to run the full app against master yet, so I cannot say whether #186826 covers this path. If a minimal repro would help I can try to extract one from the popup above.

  10. NL-CanArslan commented on Jul 6, 2026

    @NL-CanArslan

    I managed to reduce this to a self-contained reproduction with no app-specific code. It is a single main.dart plus go_router as the only extra dependency, and it throws the same three assertions.

    Environment

    Flutter 3.44.2 • channel stable • revision c9a6c48423
    Engine • revision 77e2e94772
    Tools • Dart 3.12.2
    

    Debug build. Reproduced on the iOS Simulator and on device.

    Assertions

    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3008 pos 16: '!child.attached': is not true.
    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3246 pos 12: '_owner == null': is not true.
    'package:flutter/src/rendering/object.dart': Failed assertion: line 5713 pos 12: 'node.built': is not true.
    

    Stack (entirely inside the framework, no application frames)

    #2  SemanticsNode._replaceChildren (package:flutter/src/semantics/semantics.dart:3008:16)
    #3  SemanticsNode.updateWith (package:flutter/src/semantics/semantics.dart:3750:5)
    #4  _RenderObjectSemantics._buildSemanticsSubtree (package:flutter/src/rendering/object.dart:6258:12)
    #5  _RenderObjectSemantics._produceSemanticsNode (package:flutter/src/rendering/object.dart:6272:5)
    #6  _RenderObjectSemantics._buildSemantics (package:flutter/src/rendering/object.dart:6205:7)
    #7  _RenderObjectSemantics._buildSemanticsSubtree (package:flutter/src/rendering/object.dart:6241:13)
    #8  _RenderObjectSemantics.ensureSemanticsNode (package:flutter/src/rendering/object.dart:6177:7)
    #9  PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1636:18)
    #11 RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:650:25)
    

    What triggers it

    Five things have to be present at the same time. Removing any one of 1, 4 or 5 makes the crash go away:

    1. Semantics has to be enabled. A bare emulator/simulator has no accessibility client, so nothing compiles the semantics tree and the bug stays hidden. The repro forces it on with SemanticsBinding.instance.ensureSemantics(). Turning on VoiceOver/TalkBack has the same effect.
    2. An OverlayPortal whose overlay child grafts a subtree through CompositedTransformFollower and rebuilds once after first layout (measure, then reveal with an Opacity 0 to 1 and a Transform.translate in a post-frame setState).
    3. Opening and closing those portals repeatedly.
    4. The overlay content is hosted by an OverlayEntry inserted on the root overlay, wrapping a nested Navigator and an OverlayRoute.
    5. A sibling subtree is Offstage (its semantics dropped) while all of this happens.

    Steps

    Run in debug, press "Open overlay", then rapidly tap one of the anchor circles a few times and jump between them. Within a second or two it starts throwing every frame.

    pubspec.yaml

    dependencies:
      flutter:
        sdk: flutter
      go_router: ^17.2.3

    main.dart

    import 'package:flutter/material.dart';
    import 'package:flutter/semantics.dart';
    import 'package:go_router/go_router.dart';
    
    /// Minimal, self-contained reproduction of an OverlayPortal semantics-graft
    /// crash (Flutter 3.x, debug build).
    ///
    /// Rapidly opening/closing `OverlayPortal`s (whose overlay child grafts a
    /// subtree via `CompositedTransformFollower` and rebuilds once after first
    /// layout) while a sibling subtree is `Offstage` (its semantics being dropped)
    /// crashes during `flushSemantics`.
    ///
    /// STEPS: run in debug, press "Open overlay", then rapidly tap one of the
    /// anchor buttons several times (open/close its popup quickly, and jump between
    /// anchors). Within a second or two the assertions are thrown every frame.
    ///
    /// Ingredients that all have to be present together:
    ///   1. Semantics enabled (`SemanticsBinding.instance.ensureSemantics()`, or
    ///      VoiceOver/TalkBack).
    ///   2. An `OverlayPortal` whose overlay child grafts a subtree
    ///      (`CompositedTransformFollower`) and rebuilds once after first layout
    ///      (measure-then-reveal: `Opacity` 0 -> 1 + `Transform.translate` in a
    ///      post-frame `setState`).
    ///   3. Open/close churn (tapping).
    ///   4. The overlay content is hosted by an `OverlayEntry` inserted on the
    ///      ROOT overlay, wrapping a nested `Navigator` + an `OverlayRoute`.
    ///   5. A sibling subtree is `Offstage` (its semantics dropped) meanwhile.
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      // (1) Force the semantics tree to compile so the graft path is exercised
      // even with no accessibility client attached. The handle is never released.
      SemanticsBinding.instance.ensureSemantics();
      runApp(const ReproApp());
    }
    
    final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
    final GlobalKey<NavigatorState> innerNavigatorKey =
        GlobalKey<NavigatorState>();
    
    // MaterialApp.router (not home:): a go_router Router whose root Navigator
    // (keyed to rootNavigatorKey) hosts the overlay entry. Removing the Router
    // (plain MaterialApp home:) makes the crash disappear, so it is one of the
    // required ingredients.
    final GoRouter _router = GoRouter(
      navigatorKey: rootNavigatorKey,
      routes: [GoRoute(path: '/', builder: (_, _) => const BackgroundPage())],
    );
    
    class ReproApp extends StatelessWidget {
      const ReproApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          debugShowCheckedModeBanner: false,
          routerConfig: _router,
        );
      }
    }
    
    /// (5) A background page that goes `Offstage` + `TickerMode`-disabled while the
    /// overlay is up, so its semantics subtree is dropped while the popup grafts
    /// run underneath.
    class BackgroundPage extends StatefulWidget {
      const BackgroundPage({super.key});
    
      @override
      State<BackgroundPage> createState() => _BackgroundPageState();
    }
    
    class _BackgroundPageState extends State<BackgroundPage> {
      OverlayEntry? _entry;
      bool get _overlayUp => _entry != null;
    
      void _openOverlay() {
        if (_entry != null) return;
        // (4) Host the overlay content through an OverlayEntry on the ROOT overlay,
        // wrapping a nested Navigator + an OverlayRoute.
        final entry = OverlayEntry(
          maintainState: true,
          builder: (_) => HeroControllerScope.none(
            child: Navigator(
              key: innerNavigatorKey,
              onGenerateRoute: (settings) => _BarrierlessOverlayRoute(
                settings: settings,
                builder: (_) => const OverlayField(),
              ),
            ),
          ),
        );
        _entry = entry;
        rootNavigatorKey.currentState!.overlay!.insert(entry);
        setState(() {}); // flip the background Offstage
      }
    
      @override
      void dispose() {
        _entry?.remove();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Offstage(
          offstage: _overlayUp,
          child: TickerMode(
            enabled: !_overlayUp,
            child: Scaffold(
              appBar: AppBar(title: const Text('Background')),
              body: ListView(
                children: [
                  for (var i = 0; i < 15; i++)
                    ListTile(
                      leading: const Icon(Icons.circle_outlined),
                      title: Text('Background row $i'),
                    ),
                ],
              ),
              floatingActionButton: FloatingActionButton.extended(
                onPressed: _openOverlay,
                label: const Text('Open overlay'),
              ),
            ),
          ),
        );
      }
    }
    
    /// An [OverlayRoute] (not a [ModalRoute]): no modal barrier, so it forwards
    /// pointer events through empty regions.
    class _BarrierlessOverlayRoute extends OverlayRoute<void> {
      _BarrierlessOverlayRoute({required this.builder, super.settings});
    
      final WidgetBuilder builder;
    
      @override
      RoutePopDisposition get popDisposition => RoutePopDisposition.doNotPop;
    
      @override
      Iterable<OverlayEntry> createOverlayEntries() sync* {
        yield OverlayEntry(builder: builder, maintainState: true);
      }
    }
    
    /// A ring of anchored popups. Tap one repeatedly (and jump between them) to
    /// churn the open/close.
    class OverlayField extends StatelessWidget {
      const OverlayField({super.key});
    
      static const _items = <PopupEntry>[
        PopupEntry(title: 'Action A', icon: Icons.looks_one),
        PopupEntry(title: 'Action B', icon: Icons.looks_two),
        PopupEntry(title: 'Action C', icon: Icons.looks_3),
      ];
    
      @override
      Widget build(BuildContext context) {
        const positions = <Alignment>[
          Alignment.topCenter,
          Alignment.bottomCenter,
          Alignment.centerLeft,
          Alignment.centerRight,
        ];
        return Scaffold(
          backgroundColor: const Color(0xFF2A2A2E),
          body: Stack(
            children: [
              for (var i = 0; i < positions.length; i++)
                Align(
                  alignment: positions[i],
                  child: Padding(
                    padding: const EdgeInsets.all(72),
                    child: AnchoredPopup(
                      items: _items,
                      child: Container(
                        width: 64,
                        height: 64,
                        decoration: const BoxDecoration(
                          color: Colors.white24,
                          shape: BoxShape.circle,
                        ),
                        alignment: Alignment.center,
                        child: Text(
                          'Anchor $i',
                          textAlign: TextAlign.center,
                          style: const TextStyle(color: Colors.white, fontSize: 11),
                        ),
                      ),
                    ),
                  ),
                ),
              const Align(
                alignment: Alignment.center,
                child: Text(
                  'Rapidly tap an anchor',
                  style: TextStyle(color: Colors.white70),
                ),
              ),
            ],
          ),
        );
      }
    }
    
    class PopupEntry {
      const PopupEntry({required this.title, required this.icon});
    
      final String title;
      final IconData icon;
    }
    
    /// (2) An anchored popup. Tapping the [child] opens a white flyout of [items]
    /// above it (grafted through [CompositedTransformFollower]); tapping outside
    /// dismisses.
    class AnchoredPopup extends StatefulWidget {
      const AnchoredPopup({required this.items, required this.child, super.key});
    
      final List<PopupEntry> items;
      final Widget child;
    
      @override
      State<AnchoredPopup> createState() => _AnchoredPopupState();
    }
    
    class _AnchoredPopupState extends State<AnchoredPopup> {
      final LayerLink _link = LayerLink();
      final OverlayPortalController _controller = OverlayPortalController();
    
      @override
      Widget build(BuildContext context) {
        return CompositedTransformTarget(
          link: _link,
          child: OverlayPortal(
            controller: _controller,
            overlayChildBuilder: (_) => Stack(
              children: [
                Positioned.fill(
                  child: GestureDetector(
                    behavior: HitTestBehavior.opaque,
                    onTap: _controller.hide,
                  ),
                ),
                CompositedTransformFollower(
                  link: _link,
                  targetAnchor: Alignment.topCenter,
                  followerAnchor: Alignment.bottomCenter,
                  offset: const Offset(0, -4),
                  child: _FlyoutPanel(items: widget.items),
                ),
              ],
            ),
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: _controller.show,
              child: widget.child,
            ),
          ),
        );
      }
    }
    
    class _FlyoutPanel extends StatefulWidget {
      const _FlyoutPanel({required this.items});
    
      final List<PopupEntry> items;
    
      @override
      State<_FlyoutPanel> createState() => _FlyoutPanelState();
    }
    
    class _FlyoutPanelState extends State<_FlyoutPanel> {
      final GlobalKey _panelKey = GlobalKey();
      double _bodyDx = 0;
      bool _resolved = false;
    
      @override
      void initState() {
        super.initState();
        // Measure after first layout, then reveal (opacity 0 -> 1) and nudge the
        // card back inside the screen margins: a second rebuild of the grafted
        // subtree while it is being folded into the semantics tree.
        WidgetsBinding.instance.addPostFrameCallback((_) => _resolve());
      }
    
      void _resolve() {
        if (!mounted) return;
        final box = _panelKey.currentContext?.findRenderObject() as RenderBox?;
        if (box == null || !box.hasSize) {
          if (!_resolved) setState(() => _resolved = true);
          return;
        }
        final left = box.localToGlobal(Offset.zero).dx;
        final width = box.size.width;
        final screenW = MediaQuery.sizeOf(context).width;
        const margin = 16.0;
        final clampedLeft = left.clamp(margin, screenW - margin - width);
        final bodyDx = clampedLeft - left;
        if (_resolved && (bodyDx - _bodyDx).abs() < 0.5) return;
        setState(() {
          _bodyDx = bodyDx;
          _resolved = true;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        final maxWidth = MediaQuery.sizeOf(context).width - 32;
        final body = Container(
          key: _panelKey,
          constraints: BoxConstraints(maxWidth: maxWidth),
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withValues(alpha: 0.12),
                blurRadius: 16,
                offset: const Offset(0, 4),
              ),
            ],
          ),
          child: IntrinsicWidth(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                for (var i = 0; i < widget.items.length; i++) ...[
                  _FlyoutEntry(item: widget.items[i]),
                  if (i < widget.items.length - 1) const SizedBox(height: 12),
                ],
              ],
            ),
          ),
        );
        final tail = CustomPaint(
          size: const Size(16, 8),
          painter: _FlyoutTrianglePainter(),
        );
        return Material(
          type: MaterialType.transparency,
          child: Opacity(
            opacity: _resolved ? 1 : 0,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Transform.translate(offset: Offset(_bodyDx, 0), child: body),
                tail,
              ],
            ),
          ),
        );
      }
    }
    
    class _FlyoutEntry extends StatelessWidget {
      const _FlyoutEntry({required this.item});
    
      final PopupEntry item;
    
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.all(4),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox.square(
                dimension: 20,
                child: Center(child: Icon(item.icon, size: 20, color: Colors.black)),
              ),
              const SizedBox(width: 8),
              Text(
                item.title,
                style: const TextStyle(color: Colors.black, fontSize: 16),
              ),
            ],
          ),
        );
      }
    }
    
    class _FlyoutTrianglePainter extends CustomPainter {
      const _FlyoutTrianglePainter();
    
      @override
      void paint(Canvas canvas, Size size) {
        final paint = Paint()..color = Colors.white;
        final path = Path()
          ..moveTo(0, 0)
          ..lineTo(size.width / 2, size.height)
          ..lineTo(size.width, 0)
          ..close();
        canvas.drawPath(path, paint);
      }
    
      @override
      bool shouldRepaint(_FlyoutTrianglePainter oldDelegate) => false;
    }
  11. QuncCccccc commented on Jul 8, 2026

    @QuncCccccc
    Contributor

    Flutter 3.44.2 • channel stable • revision c9a6c48

    Hi @NL-CanArslan, please try whether master branch fixes the problem. As I mentioned above, I merged c7f49fe several weeks ago but seems like it is not included in 3.44.2 stable.

    Image
  12. NL-CanArslan commented on Jul 10, 2026

    @NL-CanArslan

    Thanks for the pointer. I gave master a try and the crash still shows up with the same repro I posted above.

    Flutter 3.46.0-1.0.pre-515 • channel master • revision 8303fed06e (2026-07-10)
    Engine • hash 29c0986455
    Tools • Dart 3.14.0
    

    I double checked that #186826 is actually in this checkout:

    $ git merge-base --is-ancestor c7f49fe0fd7d372dc4e06e58267b5a3846119d46 HEAD && echo included
    included
    $ git log -1 --format="%H %s" c7f49fe0fd7d372dc4e06e58267b5a3846119d46
    c7f49fe0fd7d372dc4e06e58267b5a3846119d46 Fix mismatch between hit-test tree and traversal tree (#186826)
    

    So the fix is there, but the assertions still fire. The line numbers just moved a bit. Same stack as before, all inside the framework:

    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3013 pos 16: '!child.attached': is not true.
    'package:flutter/src/semantics/semantics.dart': Failed assertion: line 3251 pos 12: '_owner == null': is not true.
    
    #2  SemanticsNode._replaceChildren (package:flutter/src/semantics/semantics.dart:3013:16)
    #3  SemanticsNode.updateWith (package:flutter/src/semantics/semantics.dart:3783:5)
    #4  _RenderObjectSemantics._buildSemanticsSubtree (package:flutter/src/rendering/object.dart:6302:12)
    #5  _RenderObjectSemantics._produceSemanticsNode (package:flutter/src/rendering/object.dart:6318:5)
    #6  _RenderObjectSemantics._buildSemantics (package:flutter/src/rendering/object.dart:6227:7)
    #7  _RenderObjectSemantics._buildSemanticsSubtree (package:flutter/src/rendering/object.dart:6263:13)
    #8  _RenderObjectSemantics.ensureSemanticsNode (package:flutter/src/rendering/object.dart:6201:7)
    #9  PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1636:18)
    #11 RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:699:25)
    

    Looks like #186826 does not cover this particular case. Happy to test any follow up patch whenever you have one.

  13. CanArslanDev commented on Aug 3, 2026

    @CanArslanDev
    Contributor

    Picking up from the reports I posted earlier in this thread under my other account (@NL-CanArslan). Rather than collecting more stack traces I went digging in the framework source this time, and I think the actual defect is a three line asymmetry in SemanticsNode.

    All line numbers below are from 3.44.8 stable (058e0af2c2), and they happen to match the numbers in my earlier reports exactly.

    detach() guards against a stale child list, attach() does not

    detach() knows the _children list can be out of date, and says so in a comment:

    // semantics.dart:3284
    if (_children != null) {
      for (final SemanticsNode child in _children!) {
        // The list of children may be stale and may contain nodes that have
        // been assigned to a different parent.
        if (child.parent == this) {
          child.detach();
        }
      }
    }

    attach() walks the same list with no such check:

    // semantics.dart:3259
    if (_children != null) {
      for (final SemanticsNode child in _children!) {
        child.attach(owner);
      }
    }

    So when a node carrying a stale list gets attached, it drags along nodes that no longer belong to it. The result is a node with attached == true and parent == null, and every assertion in this issue and in #189902 falls out of that one state:

    • _owner == null at semantics.dart:3246, when the orphan gets attached a second time under its real parent
    • !child.attached at semantics.dart:3008, when something later tries to legitimately adopt it
    • node.built at object.dart:5713, when the flush ends with the _RenderObjectSemantics tree still dirty

    Why the list goes stale and never gets cleaned up

    _adoptChild and _dropChild deliberately do not touch the old parent's _children. That is normally harmless, because the old parent overwrites the entire list at the end of its own update (_children = newChildren, semantics.dart:3080).

    That overwrite never happens if the old parent's _RenderObjectSemantics is still marked built, because _buildSemantics short circuits before it can reach _buildSemanticsSubtree:

    // object.dart:6187
    if (!built) {
      semanticsNodes.clear();
      _producedSiblingNodesAndOwners.clear();
      _produceSemanticsNode(usedSemanticsIds: usedSemanticsIds);
    }

    And nothing marks it as needing a build when one of its semantics children is stolen. markNeedsBuild (object.dart:5748) only reacts to config, geometry and parentData changes, and there is no back reference from a SemanticsNode to the _RenderObjectSemantics that cached it. Meanwhile cachedSemanticsNode keeps the node alive across hide and show cycles, stale list and all.

    I think this is the piece @xiangh-2018 could not account for in #189902 ("for some reason (i can't explain), n#6 is not rebuilt").

    Why it is always OverlayPortal

    The "child was stolen by a deeper node" branch in _replaceChildren (semantics.dart:3000-3006) is not an exotic edge case, it is exactly how OverlayPortal grafting works. _OverlayPortalState.build wraps the anchor in Semantics(traversalParentIdentifier: this, ...) (overlay.dart:2095 and 2115), the deferred overlay child sets config.traversalChildIdentifier (overlay.dart:2694), and SemanticsOwner._traversalParentNodes / _traversalChildNodes (semantics.dart:4995 and 4998) then re-parent the node in traversal order while hit test order keeps the paint parent. One node, two parents, in two subtrees that rebuild at different times.

    Worth noting that RawTooltip is built on OverlayPortal.overlayChildLayoutBuilder (raw_tooltip.dart:886), so the Tooltip repro in #189902 is an OverlayPortal repro. Those two issues are not just hitting the same assertion, they are on the same code path.

    The AXTree error looks like the same bug in release mode

    @davidhicks980's Failed to update ui::AXTree, error: N will not be in the tree and is not the new root comes from the same orphan node. The grafting code uses attached as its liveness filter:

    // semantics.dart:4049
    for (final node in traversalChildren) {
      if (node.attached) { childrenInTraversalOrder[index] = node.id; ... }
    }
    // semantics.dart:4213
    for (final SemanticsNode node in traversalChildren) {
      if (node.attached) { updatedChildren.add(node); }
    }

    A spuriously attached orphan passes that filter and gets written into childrenInTraversalOrder as an id the engine cannot reach. The assertions are debug only, this one is not.

    Regression window

    $ git log -S"traversalParentIdentifier" -- packages/flutter/lib/src/semantics/semantics.dart
    ccf6466b14e  2025-10-29  Refactor OverlayPortal semantics (#173005)           (reverted)
    fccfa978a97  2025-11-10  Reland "Refactor OverlayPortal semantics" (#178095)
    
    $ git tag --contains fccfa978a97 | grep -E '^3\.[0-9]+\.[0-9]+$' | head -1
    3.41.0
    

    The traversal grafting machinery reached stable in 3.41.0. That lines up with the "worked on 3.38.5, broke on 3.44.5" report in #189902, and fccfa978a97 is the commit named in the title of #182604. All three issues sit downstream of the same refactor.

    Candidate fix

    Mirror the guard that detach() already has:

         if (_children != null) {
           for (final SemanticsNode child in _children!) {
    -        child.attach(owner);
    +        // The list of children may be stale and may contain nodes that have
    +        // been assigned to a different parent.
    +        if (child.parent == this) {
    +          child.attach(owner);
    +        }
           }
         }

    This looks safe by construction to me. _adoptChild sets child._parent = this before it calls child.attach(_owner!) (semantics.dart:3221), so during a genuine recursive attach every real child already satisfies the guard.

    Repro as a widget test

    I got this down to a headless widget test with no Tooltip, no hover, no timers and no extra packages. Five ingredients are needed, and the one that surprised me is the second anchor: with a single OverlayPortal I could not reproduce it at all. In @xiangh-2018's log the node that actually gets stolen belongs to the sibling anchor, not to the one being toggled, and my reduction only started failing once I made the sibling a portal anchor too.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    
    class PortalRepro extends StatefulWidget {
      const PortalRepro({super.key});
    
      @override
      State<PortalRepro> createState() => PortalReproState();
    }
    
    class PortalReproState extends State<PortalRepro> {
      final OverlayPortalController controller = OverlayPortalController();
      final OverlayPortalController siblingController = OverlayPortalController();
    
      Widget decorate(Widget child) {
        return DecoratedBox(decoration: BoxDecoration(border: Border.all()), child: child);
      }
    
      @override
      Widget build(BuildContext context) {
        // A second, never shown OverlayPortal anchor. Without it the assertion
        // does not fire.
        final Widget sibling = OverlayPortal.overlayChildLayoutBuilder(
          controller: siblingController,
          overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) =>
              const SizedBox.shrink(),
          child: Semantics(explicitChildNodes: true, child: const Text('sibling')),
        );
    
        Widget portal = OverlayPortal.overlayChildLayoutBuilder(
          controller: controller,
          overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) {
            return const Align(
              alignment: Alignment.topLeft,
              child: Text('overlay child', textDirection: TextDirection.ltr),
            );
          },
          child: Semantics(explicitChildNodes: true, child: const Text('anchor')),
        );
        portal = Overlay.wrap(child: ExcludeSemantics(child: portal));
        portal = SizedBox(width: 200, height: 100, child: portal);
    
        return Dialog(
          child: Column(
            spacing: 20,
            children: <Widget>[decorate(sibling), decorate(portal)],
          ),
        );
      }
    }
    
    void main() {
      testWidgets(
        'toggling an OverlayPortal inside a nested Overlay twice does not corrupt the semantics tree',
        (WidgetTester tester) async {
          final SemanticsHandle handle = tester.ensureSemantics();
          final GlobalKey<PortalReproState> key = GlobalKey<PortalReproState>();
    
          await tester.pumpWidget(MaterialApp(home: Scaffold(body: PortalRepro(key: key))));
          await tester.pumpAndSettle();
    
          final OverlayPortalController controller = key.currentState!.controller;
    
          for (int i = 0; i < 2; i += 1) {
            controller.show();
            await tester.pumpAndSettle();
            expect(find.text('overlay child'), findsOneWidget, reason: 'shown on pass $i');
    
            controller.hide();
            await tester.pumpAndSettle();
            expect(find.text('overlay child'), findsNothing, reason: 'hidden on pass $i');
          }
    
          handle.dispose();
        },
      );
    }

    This fails on 3.44.8 stable with '!child.attached': is not true at semantics.dart:3008 and passes with the guard applied. A Tooltip flavoured version of #189902's repro behaves identically.

    Test results with the patch applied

    • packages/flutter/test/semantics/ plus test/rendering/: 892 passed, nothing failed
    • the semantics, overlay, tooltip, menu_anchor and dropdown test files: 747 passed, nothing failed
    • full packages/flutter/test/widgets/: 7014 passed, 15 failed. Those 15 are golden image mismatches in text_golden_test.dart and box_decoration_test.dart, and they fail identically on an unpatched checkout on this machine, so they are local noise rather than a regression.

    What this does not fix

    The guard makes attached mean what the grafting code already assumes it means, and it stops the corruption from reaching the engine, but it is still treating a symptom. A _RenderObjectSemantics whose cachedSemanticsNode had a child stolen keeps a stale child list and stale geometry either way. The real fix is invalidating that cache at the moment of the theft, which needs either a back reference from SemanticsNode, or invalidation on both ends of the graft in the OverlayPortal teardown path. My guess is that the identical(childRenderObject, parentRenderObject) assertion at object.dart:6670 that @madenvel reported in #189902 is exactly that missing piece, and the guard will not help there. I have not tested that one.

    One note on the patch @xiangh-2018 proposed in #189902: removing the child from p._children before dropping it does fix the dangling reference, but it works against the invariant the framework documents in detach(), and _children can be the const <SemanticsNode>[] that updateWith substitutes when the caller passes null (semantics.dart:3750), where remove would throw.

    @QuncCccccc happy to open a PR with the guard and the two tests if that is useful, or to run any alternative patch against the repro.

  14. xiangh-2018 commented on Aug 4, 2026

    @xiangh-2018

    Hello @CanArslanDev , as you said in #189902 (comment), the proposed check is defensive. And to my taste it's always error prone to leave dangling stale refs. I agree a real fix would be more involving.

  15. CanArslanDev commented on Aug 4, 2026

    @CanArslanDev
    Contributor

    @xiangh-2018 that is a fair objection, and having gone and counted I think you are more right than my patch admits. These are the places in semantics.dart on master that walk _children:

    • _replaceChildren, two loops
    • visitChildren
    • _visitDescendants
    • _childrenInHitTestOrder
    • _updateChildrenInTraversalOrder
    • SemanticsOwner._getSemanticsActionHandlerForPosition
    • attach
    • detach

    Only detach checks child.parent == this today, and my patch takes that to two out of eight. Two of the unguarded ones feed ids straight to the engine. So "stale entries are tolerated" is an invariant that most of the file does not actually honour, and adding a seventh place that has to remember the rule is not a good long term shape. I agree with you there.

    Where I would still push back is on the patch as written:

    final p = child.parent!;
    p._children?.remove(child);
    p._dropChild(child);

    _children is the list instance the caller handed to updateWith, and updateWith substitutes const <SemanticsNode>[] when childrenInInversePaintOrder is null (semantics.dart:3750 on 3.44.8). I assumed remove would only throw when it actually finds something to remove, but that is not how it behaves:

    const <int>[].remove(1)      -> UnsupportedError: Cannot remove from an unmodifiable list
    const <int>[1, 2].remove(1)  -> UnsupportedError: Cannot remove from an unmodifiable list
    const <int>[1, 2].remove(9)  -> UnsupportedError: Cannot remove from an unmodifiable list
    

    It rejects unconditionally, so this is a crash rather than just an ownership nit.

    A copy on write version avoids that and still gets you what you want, which is no dangling refs left behind:

    final SemanticsNode p = child.parent!;
    if (p._children != null && p._children!.contains(child)) {
      p._children = List<SemanticsNode>.of(p._children!)..remove(child);
    }
    p._dropChild(child);

    I tried this on master with my attach guard removed entirely, so it was doing the work on its own. It fixes the regression test in #190431, and test/semantics, test/rendering and test/widgets come out at 8095 passing with nothing failing. The cost is one list copy on the theft path, which is rare.

    So both shapes work and neither regresses anything I can find. If reviewers prefer yours I am happy to switch the PR over to it, or carry both, since they are addressing different halves of the same invariant.

    Neither one is the real fix though, as you say. After the theft the cached _RenderObjectSemantics still holds a stale node and stale geometry regardless of which patch is in, so I would expect @madenvel's computeChildGeometry assertion to survive both. That one needs the invalidation, and I am happy to take that on if the team wants to go straight there instead.

  16. xiangh-2018 commented on Aug 5, 2026

    @xiangh-2018

    Hello @CanArslanDev, thanks for your hard work. I checked far less code paths than you did, and my fix is more a PoC than real fix. Yours is safer.
    I can not connect the assertion that @madenvel reported in #189902 with this, because that looks like a bad RenderObject/_RenderObjectSemantics parent/child relation, while this is, superficially, a bad SemanticsNode p/c relation.
    I guess you are right about "The real fix is invalidating that cache at the moment of the theft", because in my test if the SemanticsNode whose child has been stolen, is rebuilt, the assertion won't fire. I haven't looked into the OverlayPortal teardown path which looks suspicious as per your comments.

  17. CanArslanDev commented on Aug 5, 2026

    @CanArslanDev
    Contributor

    @xiangh-2018 thanks, and it turns out you were pointing at the right thing all along. I went and prototyped the invalidation before replying, and it does not work, so I have updated #190431 to go your way instead.

    The problem is that the theft always happens inside flushSemantics, and the framework blocks dirtying semantics from there in three separate places:

    • markNeedsBuild() on the old parent's _RenderObjectSemantics leaves the flush with a dirty node, so debugCheckForBuilds trips assert(node.built)
    • RenderObject.markNeedsSemanticsUpdate() asserts !attached || !owner!._debugDoingSemantics
    • PipelineOwner.flushSemantics asserts _nodesNeedingSemanticsUpdate.isEmpty on the way out

    I tried the first two against the repro. Both fire. The flush is built to reach a fixpoint in one pass, so "invalidate now, rebuild later" has nowhere to put the later. The repair has to be synchronous, which is what your patch was doing.

    An aside that amused me: the naive markNeedsBuild version fails with assert(node.built), which is one of the three assertion variants originally reported in this issue. So that variant is probably what it looks like when something dirties semantics mid flush.

    The other thing I checked is where stale entries can come from at all. _dropChild has exactly two callers, both in _replaceChildren. The one in the dead child loop is harmless because that node replaces its whole list a few lines later. The one in the steal branch is the only place that can leave a stale entry behind. So repairing it there closes the source completely, and your instinct that dangling refs should not be left lying around was the correct read.

    The PR now does both. _replaceChildren repairs the old parent's list when it takes a child, replacing the list rather than mutating it so the const case cannot throw, and attach keeps the guard as hardening for the other readers you made me go count. Either one fixes the repro on its own, and everything stays green with both.

    On @madenvel's assertion, I think your reading is right and mine was sloppy. It is a RenderObject to _RenderObjectSemantics relationship going bad, not a SemanticsNode parent and child one, and nothing here touches that. It probably wants its own issue rather than being folded into this one.

  18. CanArslanDev commented on Sep 6, 2026

    @CanArslanDev
    Contributor

    Coming back to correct two things I said in this thread, because both turned out to be wrong and both of them point away from the right answer.

    Invalidating the cache is possible. In my comment above I said I had prototyped it and it could not be done, because the theft always happens inside flushSemantics and markNeedsBuild, RenderObject.markNeedsSemanticsUpdate and the flushSemantics exit assert all refuse to be dirtied from there. Those three asserts are real, but the conclusion was too broad. It is the moment of the theft that cannot be used, because that is the middle of the build phase. The decision that leads to the theft is made somewhere else: _hasSiblingConflict is set in _marksConflictsInMergeGroup, which runs from updateChildren during the parent data phase, before the build loop. Marking there is picked up in the same flush and none of the three fire.

    @chunhtai made this point on the PR and was right about it. The concrete gap is that shouldFormSemanticsNode ends in parentData!.explicitChildNodes || _hasSiblingConflict and only the first half is invalidated. _didUpdateParentData calls markNeedsBuild with the comment "Parent data changes may result in node formation changes". markSiblingConfigurationConflict assigns the flag and returns. So a fragment can stop forming a node, keep built and its cachedSemanticsNode, have its children taken while it is out of the tree, and be handed back in still holding them. #190431 now marks the fragment when that flag changes, and both of the patches I was arguing about here are gone.

    #182604 is not the same defect. I said here, and again on #189902, that this issue, #189902 and #182604 all come down to one thing. #182604 was fixed a few days ago by #192139, and it was an engine side iOS bug: setChildrenInHitTestOrder was mutating child.parent, so Overlay overwrote the traversal parent that OverlayPortal had just set, and the nodes ended up anchored to the wrong origin. Both sit downstream of fccfa978a97 splitting traversal order from hit test order, but that is where the shared history ends. "After the same commit" is not the same as "the same bug" and I should not have written it that way.

    One smaller correction while I am here. I listed eight places in semantics.dart that walk _children. It is ten. _redepthChildren and _updateChildrenMergeFlags walk it too, and both assert on child.owner, so a stale entry can trip in those as well. detach is still the only one that checks whether the child is actually still its own.

  19. added
    ⏳Bot is counting down the days until it unassigns the issue
    on Nov 11, 2026
  20. flutter-triage-bot commented on Nov 11, 2026

    @flutter-triage-bot

    This issue is assigned to @QuncCccccc but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

⏳Bot is counting down the days until it unassigns the issueP2Important issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)triaged-accessibilityTriaged by Framework Accessibility team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions