From 544752fe7f98981d8303a684aacf892789f9a2e8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 27 Sep 2026 23:40:36 +0300 Subject: [PATCH] fix(shadcn_flutter): sync _children when ContextMenu switches between items and itemsBuilder (#1011) --- .../context_menu_mode_switch_test.dart | 187 ++++++++++++++++++ .../lib/src/components/menu/context_menu.dart | 18 +- 2 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 test/features/workspace/context_menu_mode_switch_test.dart diff --git a/test/features/workspace/context_menu_mode_switch_test.dart b/test/features/workspace/context_menu_mode_switch_test.dart new file mode 100644 index 0000000..6fb51ed --- /dev/null +++ b/test/features/workspace/context_menu_mode_switch_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +material.Widget _testShell({required material.Widget child}) { + final td = QueryaTheme.darkDefault + .toShadcnThemeData() + .copyWith(platform: () => material.TargetPlatform.linux); + return ShadcnApp( + theme: td, + home: material.Scaffold( + body: child, + ), + ); +} + +Future _secondaryClick(WidgetTester tester, Finder finder) async { + final gesture = await tester.startGesture( + tester.getCenter(finder), + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +class _DynamicContextMenuTestHost extends material.StatefulWidget { + const _DynamicContextMenuTestHost({ + super.key, + required this.initialItems, + this.initialItemsBuilder, + }); + + final List? initialItems; + final List Function(material.BuildContext)? initialItemsBuilder; + + @override + State<_DynamicContextMenuTestHost> createState() => + _DynamicContextMenuTestHostState(); +} + +class _DynamicContextMenuTestHostState + extends material.State<_DynamicContextMenuTestHost> { + late List? items = widget.initialItems; + late List Function(material.BuildContext)? itemsBuilder = + widget.initialItemsBuilder; + + void update({ + List? newItems, + List Function(material.BuildContext)? newItemsBuilder, + }) { + setState(() { + items = newItems; + itemsBuilder = newItemsBuilder; + }); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Center( + child: ContextMenu( + items: items, + itemsBuilder: itemsBuilder, + child: const material.Text('Target Box'), + ), + ); + } +} + +void main() { + group('ContextMenu mode switching (#1011)', () { + testWidgets( + 'switches from itemsBuilder to items at runtime without throwing', + (tester) async { + final hostKey = + material.GlobalKey<_DynamicContextMenuTestHostState>(); + + await tester.pumpWidget( + _testShell( + child: _DynamicContextMenuTestHost( + key: hostKey, + initialItems: null, + initialItemsBuilder: (context) => [ + const MenuButton(child: Text('Lazy Item 1')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + // Switch to eager items without itemsBuilder + hostKey.currentState!.update( + newItems: [ + const MenuButton(child: Text('Eager Item 1')), + ], + newItemsBuilder: null, + ); + await tester.pumpAndSettle(); + + // Right-clicking the target box must resolve eager items and open the menu + // without throwing "Null check operator used on a null value". + await _secondaryClick(tester, find.text('Target Box')); + await tester.pumpAndSettle(); + + expect(find.text('Eager Item 1'), findsOneWidget); + expect(find.text('Lazy Item 1'), findsNothing); + }); + + testWidgets( + 'switches from items to itemsBuilder at runtime and invokes builder', + (tester) async { + final hostKey = + material.GlobalKey<_DynamicContextMenuTestHostState>(); + + await tester.pumpWidget( + _testShell( + child: _DynamicContextMenuTestHost( + key: hostKey, + initialItems: const [ + MenuButton(child: Text('Old Eager Item')), + ], + initialItemsBuilder: null, + ), + ), + ); + await tester.pumpAndSettle(); + + var builderCallCount = 0; + + // Switch to lazy itemsBuilder mode + hostKey.currentState!.update( + newItems: null, + newItemsBuilder: (context) { + builderCallCount++; + return [ + const MenuButton(child: Text('New Lazy Item')), + ]; + }, + ); + await tester.pumpAndSettle(); + + expect(builderCallCount, 0); + + // Open context menu — should call itemsBuilder, not stale eager children + await _secondaryClick(tester, find.text('Target Box')); + await tester.pumpAndSettle(); + + expect(builderCallCount, 1); + expect(find.text('New Lazy Item'), findsOneWidget); + expect(find.text('Old Eager Item'), findsNothing); + }); + + testWidgets('updates items when remaining in eager mode', (tester) async { + final hostKey = + material.GlobalKey<_DynamicContextMenuTestHostState>(); + + await tester.pumpWidget( + _testShell( + child: _DynamicContextMenuTestHost( + key: hostKey, + initialItems: const [ + MenuButton(child: Text('Version 1 Item')), + ], + initialItemsBuilder: null, + ), + ), + ); + await tester.pumpAndSettle(); + + hostKey.currentState!.update( + newItems: const [ + MenuButton(child: Text('Version 2 Item')), + ], + newItemsBuilder: null, + ); + await tester.pumpAndSettle(); + + await _secondaryClick(tester, find.text('Target Box')); + await tester.pumpAndSettle(); + + expect(find.text('Version 2 Item'), findsOneWidget); + expect(find.text('Version 1 Item'), findsNothing); + }); + }); +} diff --git a/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart index 60f540c..1da7097 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart @@ -591,12 +591,18 @@ class _ContextMenuState extends State { super.didUpdateWidget(oldWidget); final items = widget.items; final oldItems = oldWidget.items; - if (items != null && - oldItems != null && - !listEquals(items, oldItems)) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) _children?.value = items; - }); + if (items != null) { + if (oldItems == null || _children == null) { + _children?.dispose(); + _children = ValueNotifier(items); + } else if (!listEquals(items, oldItems)) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (mounted) _children?.value = items; + }); + } + } else if (oldItems != null || _children != null) { + _children?.dispose(); + _children = null; } }