Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 0462d19

Browse files
authored
Fix unexpected ViewFocus events when Text Editing utilities change focus in the middle of a blur call. (#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 />`. flutter/flutter#153022 [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
1 parent c50eb8a commit 0462d19

File tree

3 files changed

+38
-0
lines changed

3 files changed

+38
-0
lines changed

lib/web_ui/lib/src/engine/dom.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,10 @@ extension DomHTMLDocumentExtension on DomHTMLDocument {
339339
@JS('visibilityState')
340340
external JSString get _visibilityState;
341341
String get visibilityState => _visibilityState.toDart;
342+
343+
@JS('hasFocus')
344+
external JSBoolean _hasFocus();
345+
bool hasFocus() => _hasFocus().toDart;
342346
}
343347

344348
@JS('document')

lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ final class ViewFocusBinding {
6464
});
6565

6666
late final DomEventListener _handleFocusout = createDomEventListener((DomEvent event) {
67+
// During focusout processing, activeElement typically points to <body />.
68+
// However, if an element is focused during a blur event, activeElement points to that focused element.
69+
// We leverage this behavior to ignore focusout events where the document has focus but activeElement is not <body />.
70+
//
71+
// Refer to https://github.com/flutter/engine/pull/54965 for more info.
72+
final bool wasFocusInvoked = domDocument.hasFocus() && domDocument.activeElement != domDocument.body;
73+
if (wasFocusInvoked) {
74+
return;
75+
}
76+
6777
event as DomFocusEvent;
6878
_handleFocusChange(event.relatedTarget as DomElement?);
6979
});

lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,30 @@ void testMain() {
270270
expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused);
271271
expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward);
272272
});
273+
274+
test('works even if focus is changed in the middle of a blur call', () {
275+
final DomElement input1 = createDomElement('input');
276+
final DomElement input2 = createDomElement('input');
277+
final EngineFlutterView view = createAndRegisterView(dispatcher);
278+
final DomEventListener focusInput1Listener = createDomEventListener((DomEvent event) {
279+
input1.focusWithoutScroll();
280+
});
281+
282+
view.dom.rootElement.append(input1);
283+
view.dom.rootElement.append(input2);
284+
285+
input1.addEventListener('blur', focusInput1Listener);
286+
input1.focusWithoutScroll();
287+
// The event handler above should move the focus back to input1.
288+
input2.focusWithoutScroll();
289+
input1.removeEventListener('blur', focusInput1Listener);
290+
291+
expect(dispatchedViewFocusEvents, hasLength(1));
292+
293+
expect(dispatchedViewFocusEvents[0].viewId, view.viewId);
294+
expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused);
295+
expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward);
296+
});
273297
});
274298
}
275299

0 commit comments

Comments
 (0)