Fix unexpected ViewFocus events when Text Editing utilities change focus in the middle of a blur call. (flutter/engine#54965)

In [some cases][1], text editing utilities re-focus the `<input />` element during a blur event. This causes an unusual sequence of `focusin` and `focusout` events, leading to the engine sending unintended events.

Consider the following HTML code:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <div id="container">
    <input type="" value="1" id="input1">
    <input type="" value="2" id="input2">
    <input type="" value="3" id="input3">
  </div>

  <script>
    container.addEventListener('focusin', (ev) => {
      console.log('focusin: focus was gained by', ev.target);
    });
    container.addEventListener('focusout', (ev) => {
      console.log('focusout: focus is leaving', ev.target, 'and it will go to', ev.relatedTarget);
    });
  </script>
</body>
</html>
```

Clicking input1, then input2, then input3 produces the following console logs:

```
// Input1 is clicked
focusin: focus was gained by <input type value=​"1" id=​"input1">​

// Input2 is clicked
focusout: focus is leaving <input type value=​"1" id=​"input1">​ and it will go to <input type value=​"2" id=​"input2">​
focusin: focus was gained by <input type value=​"2" id=​"input2">​

// Input3 is clicked
focusout: focus is leaving <input type value=​"2" id=​"input2">​ and it will go to <input type value=​"3" id=​"input3">​
focusin: focus was gained by <input type value=​"3" id=​"input3">​
```

Now, let's add a blur handler that changes focus:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <div id="container">
    <input type="" value="1" id="input1">
    <input type="" value="2" id="input2">
    <input type="" value="3" id="input3">
  </div>

  <script>
    container.addEventListener('focusin', (ev) => {
      console.log('focusin: focus was gained by', ev.target);
    });
    container.addEventListener('focusout', (ev) => {
      console.log('focusout: focus is leaving', ev.target, 'and it will go to', ev.relatedTarget);
    });
    input2.addEventListener('blur', (ev) => {
      input2.focus();
    });
  </script>
</body>
</html>
```

The log sequence changes and gives the wrong impression that no dom element has focus:

```
// Input1 is clicked
focusin: focus was gained by <input type value=​"1" id=​"input1">​

// Input2 is clicked
focusout: focus is leaving <input type value=​"1" id=​"input1">​ and it will go to <input type value=​"2" id=​"input2">​
focusin: focus was gained by <input type value=​"2" id=​"input2">​

// Input3 is clicked, but the handler kicks in and instead of the following line being a focusout, it results in a focusin call first.
focusin: focus was gained by <input type value=​"2" id=​"input2">​
focusout: focus is leaving <input type value=​"2" id=​"input2">​ and it will go to null
```

In addition to that, during `focusout` processing, `activeElement` typically points to `<body />`. However, if an element is focused during a `blur` event, `activeElement` points to that focused element.  Although, undocumented it can be verified with:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <div id="container">
    <input type="" value="1" id="input1">
    <input type="" value="2" id="input2">
    <input type="" value="3" id="input3">
  </div>

  <script>
    container.addEventListener('focusin', (ev) => {
      console.log('focusin: was gained by', ev.target);
    });
    container.addEventListener('focusout', (ev) => {
      console.log('document.hasFocus()', document.hasFocus());     
      console.log('document.activeElement', document.activeElement);
      console.log('focusout: focus is leaving', ev.target, 'and it will go to', ev.relatedTarget);
    });
    input2.addEventListener('blur', (ev) => {
      input2.focus();
    });
  </script>
</body>
</html>
```

We leverage these behaviors to ignore `focusout` events when the document has focus but `activeElement` is not `<body />`.

https://github.com/flutter/flutter/issues/153022

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
Juanjo Tugores 2024-09-05 11:23:55 -07:00 committed by GitHub
parent 31a579bf40
commit cf40f73057
3 changed files with 38 additions and 0 deletions

View File

@ -339,6 +339,10 @@ extension DomHTMLDocumentExtension on DomHTMLDocument {
@JS('visibilityState')
external JSString get _visibilityState;
String get visibilityState => _visibilityState.toDart;
@JS('hasFocus')
external JSBoolean _hasFocus();
bool hasFocus() => _hasFocus().toDart;
}
@JS('document')

View File

@ -64,6 +64,16 @@ final class ViewFocusBinding {
});
late final DomEventListener _handleFocusout = createDomEventListener((DomEvent event) {
// During focusout processing, activeElement typically points to <body />.
// However, if an element is focused during a blur event, activeElement points to that focused element.
// We leverage this behavior to ignore focusout events where the document has focus but activeElement is not <body />.
//
// Refer to https://github.com/flutter/engine/pull/54965 for more info.
final bool wasFocusInvoked = domDocument.hasFocus() && domDocument.activeElement != domDocument.body;
if (wasFocusInvoked) {
return;
}
event as DomFocusEvent;
_handleFocusChange(event.relatedTarget as DomElement?);
});

View File

@ -270,6 +270,30 @@ void testMain() {
expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused);
expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward);
});
test('works even if focus is changed in the middle of a blur call', () {
final DomElement input1 = createDomElement('input');
final DomElement input2 = createDomElement('input');
final EngineFlutterView view = createAndRegisterView(dispatcher);
final DomEventListener focusInput1Listener = createDomEventListener((DomEvent event) {
input1.focusWithoutScroll();
});
view.dom.rootElement.append(input1);
view.dom.rootElement.append(input2);
input1.addEventListener('blur', focusInput1Listener);
input1.focusWithoutScroll();
// The event handler above should move the focus back to input1.
input2.focusWithoutScroll();
input1.removeEventListener('blur', focusInput1Listener);
expect(dispatchedViewFocusEvents, hasLength(1));
expect(dispatchedViewFocusEvents[0].viewId, view.viewId);
expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused);
expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward);
});
});
}