flutter / flutter

Flutter makes it easy and fast to build beautiful apps for mobile and beyond
https://flutter.dev
BSD 3-Clause "New" or "Revised" License
165.1k stars 27.22k forks source link

`FocusScopeNode.descendantsAreFocusable` set to false after disabling and enabling `canRequestFocus` #147256

Open knopp opened 5 months ago

knopp commented 5 months ago

Happens with current main (3.22.0-16.0.pre.26).

Widget test to reproduce:

  testWidgets('FocusScope descendantsAreFocusable', (WidgetTester tester) async {
    final focusScopeNode = FocusScopeNode();
    await tester.pumpWidget(
      FocusScope(
        node: focusScopeNode,
        child: const SizedBox.shrink(),
      ),
    );
    expect(focusScopeNode.descendantsAreFocusable, isTrue);

    focusScopeNode.canRequestFocus = false;
    expect(focusScopeNode.descendantsAreFocusable, isFalse);

    focusScopeNode.canRequestFocus = true;
    expect(focusScopeNode.descendantsAreFocusable, isTrue);

    // Disabling canRequestFocus and updating the widget will permanently disable descendantsAreFocusable.
    focusScopeNode.canRequestFocus = false;

    // After this pump, setting canRequestFocus back will not restore
    // descendantsAreFocusable to true.
    await tester.pumpWidget(
      FocusScope(
        node: focusScopeNode,
        child: const SizedBox.shrink(),
      ),
    );

    focusScopeNode.canRequestFocus = true;

    // Test failure:
    // This should be back to true, but in FocusNode state didUpdateWidget the 
    // descendantsAreFocusable on the focusScopeNode got set to false.
    expect(focusScopeNode.descendantsAreFocusable, isTrue);
  });

This break focus in _ModalScopeState which sets canRequestFocus on the scope node and then rebuilds widget, which in turns sets the descendantsAreFocusable to false.

@gspencergoog, any thoughts? The focus code is quite convoluted, in this case the _FocusState.didUpdateWidget actually overrides property on FocusScopeNode, which is quite surprising.

knopp commented 5 months ago

This is not a recent regression, but I think in our app we trigger rebuild in _ModalScopeState while the canRequestFocus property is set to false on the scope focus node, which leaves the focus scope node with descendantsAreFocusable permanently set to false. I think the primary problem is demonstrated in the example.

darshankawar commented 5 months ago

Below is the console error log after running the provided test:

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: true
  Actual: <false>

When the exception was thrown, this was the stack:
#4      main.<anonymous closure> (file:///Users/dhs/Documents/NCFlutter/app_foo_stable/test/widget_test.dart:50:5)
<asynchronous suspension>
#5      testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:183:15)
<asynchronous suspension>
#6      TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1017:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)

This was caught by the test expectation on the following line:
  file:///Users/dhs/Documents/NCFlutter/app_foo_stable/test/widget_test.dart line 50
The test description was:
  FocusScope descendantsAreFocusable
════════════════════════════════════════════════════════════════════════════════════════════════════

Test failed. See exception logs above.
The test description was: FocusScope descendantsAreFocusable

Above occurs on latest master and stable.

stable, master flutter doctor -v ``` [!] Flutter (Channel stable, 3.19.6, on macOS 12.2.1 21D62 darwin-x64, locale en-GB) • Flutter version 3.19.6 on channel stable at /Users/dhs/documents/fluttersdk/flutter ! Warning: `flutter` on your path resolves to /Users/dhs/Documents/Fluttersdk/flutter/bin/flutter, which is not inside your current Flutter SDK checkout at /Users/dhs/documents/fluttersdk/flutter. Consider adding /Users/dhs/documents/fluttersdk/flutter/bin to the front of your path. ! Warning: `dart` on your path resolves to /Users/dhs/Documents/Fluttersdk/flutter/bin/dart, which is not inside your current Flutter SDK checkout at /Users/dhs/documents/fluttersdk/flutter. Consider adding /Users/dhs/documents/fluttersdk/flutter/bin to the front of your path. • Upstream repository https://github.com/flutter/flutter.git • Framework revision 54e66469a9 (6 days ago), 2024-04-17 13:08:03 -0700 • Engine revision c4cd48e186 • Dart version 3.3.4 • DevTools version 2.31.1 • If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades. [!] Xcode - develop for iOS and macOS (Xcode 12.3) • Xcode at /Applications/Xcode.app/Contents/Developer ! Flutter recommends a minimum Xcode version of 13. Download the latest version or update via the Mac App Store. • CocoaPods version 1.11.2 [✓] Chrome - develop for the web • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome [✓] VS Code (version 1.62.0) • VS Code at /Applications/Visual Studio Code.app/Contents • Flutter extension version 3.21.0 [✓] Connected device (5 available) • SM G975F (mobile) • RZ8M802WY0X • android-arm64 • Android 11 (API 30) • Darshan's iphone (mobile) • 21150b119064aecc249dfcfe05e259197461ce23 • ios • iOS 14.4.1 18D61 • iPhone 12 Pro Max (mobile) • A5473606-0213-4FD8-BA16-553433949729 • ios • com.apple.CoreSimulator.SimRuntime.iOS-14-3 (simulator) • macOS (desktop) • macos • darwin-x64 • Mac OS X 10.15.4 19E2269 darwin-x64 • Chrome (web) • chrome • web-javascript • Google Chrome 98.0.4758.80 [✓] HTTP Host Availability • All required HTTP hosts are available ! Doctor found issues in 1 category. [!] Flutter (Channel master, 3.22.0-14.0.pre.67, on macOS 12.2.1 21D62 darwin-x64, locale en-GB) • Flutter version 3.22.0-14.0.pre.67 on channel master at /Users/dhs/documents/fluttersdk/flutter ! Warning: `flutter` on your path resolves to /Users/dhs/Documents/Fluttersdk/flutter/bin/flutter, which is not inside your current Flutter SDK checkout at /Users/dhs/documents/fluttersdk/flutter. Consider adding /Users/dhs/documents/fluttersdk/flutter/bin to the front of your path. ! Warning: `dart` on your path resolves to /Users/dhs/Documents/Fluttersdk/flutter/bin/dart, which is not inside your current Flutter SDK checkout at /Users/dhs/documents/fluttersdk/flutter. Consider adding /Users/dhs/documents/fluttersdk/flutter/bin to the front of your path. • Upstream repository https://github.com/flutter/flutter.git • Framework revision 1a905d508d (20 hours ago), 2024-04-21 06:44:23 -0400 • Engine revision 75ca2195c9 • Dart version 3.5.0 (build 3.5.0-83.0.dev) • DevTools version 2.35.0-dev.8 • If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades. [!] Android toolchain - develop for Android devices (Android SDK version 30.0.3) • Android SDK at /Users/dhs/Library/Android/sdk ✗ cmdline-tools component is missing Run `path/to/sdkmanager --install "cmdline-tools;latest"` See https://developer.android.com/studio/command-line for more details. ✗ Android license status unknown. Run `flutter doctor --android-licenses` to accept the SDK licenses. See https://flutter.dev/docs/get-started/install/macos#android-setup for more details. [✓] Xcode - develop for iOS and macOS (Xcode 13.2.1) • Xcode at /Applications/Xcode.app/Contents/Developer • Build 13C100 • CocoaPods version 1.11.2 [✓] Chrome - develop for the web • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome [✓] IntelliJ IDEA Ultimate Edition (version 2021.3.2) • IntelliJ at /Applications/IntelliJ IDEA.app • Flutter plugin version 65.1.4 • Dart plugin version 213.7228 [✓] VS Code (version 1.62.0) • VS Code at /Applications/Visual Studio Code.app/Contents • Flutter extension version 3.29.0 [✓] Connected device (3 available) • Darshan's iphone (mobile) • 21150b119064aecc249dfcfe05e259197461ce23 • ios • iOS 15.3.1 19D52 • macOS (desktop) • macos • darwin-x64 • macOS 12.2.1 21D62 darwin-x64 • Chrome (web) • chrome • web-javascript • Google Chrome 109.0.5414.119 [✓] Network resources • All expected network resources are available. ! Doctor found issues in 1 category. [!] Xcode - develop for iOS and macOS (Xcode 12.3) • Xcode at /Applications/Xcode.app/Contents/Developer ! Flutter recommends a minimum Xcode version of 13. Download the latest version or update via the Mac App Store. • CocoaPods version 1.11.2 [✓] Chrome - develop for the web • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome [✓] VS Code (version 1.62.0) • VS Code at /Applications/Visual Studio Code.app/Contents • Flutter extension version 3.21.0 [✓] Connected device (5 available) • SM G975F (mobile) • RZ8M802WY0X • android-arm64 • Android 11 (API 30) • Darshan's iphone (mobile) • 21150b119064aecc249dfcfe05e259197461ce23 • ios • iOS 14.4.1 18D61 • iPhone 12 Pro Max (mobile) • A5473606-0213-4FD8-BA16-553433949729 • ios • com.apple.CoreSimulator.SimRuntime.iOS-14-3 (simulator) • macOS (desktop) • macos • darwin-x64 • Mac OS X 10.15.4 19E2269 darwin-x64 • Chrome (web) • chrome • web-javascript • Google Chrome 98.0.4758.80 [✓] HTTP Host Availability • All required HTTP hosts are available ! Doctor found issues in 1 category. ```
LongCatIsLooong commented 5 months ago

I think using the Focus.withExternalFocusNode/FocusScope.withExternalFocusNode constructors would prevent the widget/element from modifying the node. Maybe the default constructors shouldn't take an external node?

knopp commented 5 months ago

My issue here is that it modifies completely unrelated property but doesn't set it back. And the behavior depends on whether the widget gets rebuilt or not, which is quite confusing. This actually causes a bug in modal scope:

https://github.com/flutter/flutter/blob/65e657799fb596bf39bdc1e62756fa1f9c416efe/packages/flutter/lib/src/widgets/routes.dart#L1069-L1075

On line 1071, _ModalScopeState temporarily sets canRequestFocus to false. Then, in unlucky scenario, when the FocusScope widget of _ModalScopeState gets rebuilt while canRequestFocus is set to false, the node gets stuck with descendantsAreFocusable == false forever, essentially breaking focus in the application.

So either _ModalScopeState (and everyone else setting canRequestFocus) must be aware that this can lead to disabling (but not reenabling) descendantsAreFocusable on the scope node, or the scope node should handle this better.

knopp commented 5 months ago

As for default constructor not taking a node, that would be a pretty major breaking change. Though TBH the withExternalFocusNode constructor was always a bit confusing to me considering that default constructor also takes (optional) focus node.

gspencergoog commented 5 months ago

(Sorry, I was OOO for a few weeks) The history here is that we would very much like to get rid of the node argument in the normal constructor, but it was too large of a breaking change to do. The added withExternalFocus constructors are an attempt to provide the right functionality without breaking things. If I had it to do over again, we would have not included node in the constructor.

In any case, if you are modifying properties of the focus node after you supply it to the FocusScope widget, you should use the withExternalFocus constructor. Otherwise, the FocusScope widget may end up overwriting them in ways you don't expect.

knopp commented 5 months ago

@gspencergoog, in this case it's not me modifying it, but modal route in the framework:

https://github.com/flutter/flutter/blob/65e657799fb596bf39bdc1e62756fa1f9c416efe/packages/flutter/lib/src/widgets/routes.dart#L1071

Should it be fixed to use withExternalFocusNode?

Also, is there a valid use-case for specifying the node argument in default constructor rather than the .withExternalFocusNode constructor? If there isn't any then maybe it would be worth considering the doing breaking change.

gspencergoog commented 5 months ago

Also, is there a valid use-case for specifying the node argument in default constructor rather than the .withExternalFocusNode constructor? If there isn't any then maybe it would be worth considering the doing breaking change.

If the only reason you're passing the focus node is to be able to call requestFocus from someplace else, then it's valid to pass it in the default constructor.

Should it be fixed to use withExternalFocusNode?

Yes, I think you're right. This line: https://github.com/flutter/flutter/blob/65e657799fb596bf39bdc1e62756fa1f9c416efe/packages/flutter/lib/src/widgets/routes.dart#L1051

should be fixed to use withExternalFocusNode.

I'll put together a PR.

b3nni97 commented 5 months ago

Is this issue potentially related to issue #146844?

gspencergoog commented 5 months ago

Is this issue potentially related to issue https://github.com/flutter/flutter/issues/146844?

Potentially, since the focus node's focusability is predicated on whether or not it's in a user gesture (like a back swipe). It depends on what the root cause is, though.

gspencergoog commented 5 months ago

@knopp I created https://github.com/flutter/flutter/pull/147390 , but I was unable to find a case to test where it made a difference. It is still the right way to do things, but since the route's focusScopeNode is never changed (is immutable), the FocusScope widget shouldn't overwrite the skipTraversal or canRequestFocus attributes anyhow.

knopp commented 5 months ago

@gspencergoog, I think something similar to https://github.com/flutter/flutter/issues/146844 should work? For us the problem also manifested when using the backswipe gesture on iOS. I'm guessing something there triggers the rebuild in the modal scope state (which updates the FocusScope widget which in turn disables descendantsAreFocusable on the node).

gspencergoog commented 5 months ago

@knopp Yeah, after I wrote that comment above, @b3nni97 mentioned that it fixed https://github.com/flutter/flutter/issues/146844, so it clearly is doing something that I should be able to test. Was this regression case only visible on iOS, or did it happen more generally?