Skip to content

Commit 4f3eade

Browse files
authored
fix: Update focusNode to self correct focus (#9082)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes RaspberryPiFoundation/blockly-keyboard-experimentation#87 ### Proposed Changes This updates `FocusManager.focusNode()` to automatically defocus its internal state if it detects that DOM focus (per `document.activeElement`) doesn't match its own internal focus. It also updates `FocusManager` to avoid duplicate self calls to `focusNode()`. ### Reason for Changes This is a robustness improvement for `focusNode` that is nice to keep as a "if all else fails" mechanism, but it's currently a hacky workaround to RaspberryPiFoundation/blockly-keyboard-experimentation#87. #9081 is tracking introducing a long-term fix for the desynchronizing problem, but that's likely to be potentially much harder to solve and this at least introduces a reasonable correction. From a stability perspective, it seems likely that there are multiple classes of failures covered by this fix. Essentially the browser behavior difference in Firefox and Safari over Chrome is that the former do not fire a focus change event when a focused element is removed from the DOM (leading to `FocusManager` getting out of sync). There may be other such cases when a focus event isn't fired, so this robustness improvement at least ensures eventual consistency so long as `focusNode()` is called (and, fortunately, that's done a lot now). While this is a nice robustness improvement, it's not a perfect replacement for a real fix. For the time between `FocusManager` getting out of sync and `focusNode` getting called, `getFocusedNode` will _not_ match the actual element holding focus. The primary class of issues known is when a DOM element is being moved, and in these cases `focusNode` _is_ called. If there are other such unknown cases where a desync can happen, **`getFocusedNode()` will remain wrong until a later `focusNode()` call**. Note one other change: originally implemented but removed in earlier PRs for `FocusManager`, this change also includes ensuring `focusNode()` isn't called multiple times for a single request to focus a node. Current logic results in a call to `focusNode()` calling a node's `focus()` which then processes a second call to `focusNode()` (which is fully executed because `FocusManager.focusedNode` isn't updated until after the call to `focus()`). This doesn't actually correct any state, but it's more efficient and provides some resilience against potential logic issues from calling node/tree callbacks multiple times (which was observed up to 3 times in some cases). ### Test Coverage This has been tested via the keyboard navigation experimental plugin's test environment (with Firefox), plus new unit tests. Note the new test for directly verifying desyncing logic is contrived, but it should be perfectly testing the exact scenario that's being observed on Firefox/Safari. A separate test was added for the existing behavior of focusing a different node still correcting `FocusManager` state even if it was desynced (the bug only happens for the same node being refocused). New tests were also added for the various lifecycle callbacks (to ensure they aren't called multiple times). All of the new tests were verified to fail without the two fixes in place (they were verified in isolation), minus the test for focusing a second node when desynced (since that should pass regardless of the new fixes). Some basic simple playground testing was done, as well, just to verify nothing obvious was broken around selection, gestures, and copy/paste. ### Documentation No new documentation should be needed here. ### Additional Information This wasn't explicitly tested in Safari since I only have access to Chrome and Firefox, but I will ask someone else on the team to verify this for me after merging if it isn't checked sooner.
1 parent e4d7245 commit 4f3eade

File tree

2 files changed

+110
-1
lines changed

2 files changed

+110
-1
lines changed

core/focus_manager.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,16 @@ export class FocusManager {
6363
private currentlyHoldsEphemeralFocus: boolean = false;
6464
private lockFocusStateChanges: boolean = false;
6565
private recentlyLostAllFocus: boolean = false;
66+
private isUpdatingFocusedNode: boolean = false;
6667

6768
constructor(
6869
addGlobalEventListener: (type: string, listener: EventListener) => void,
6970
) {
7071
// Note that 'element' here is the element *gaining* focus.
7172
const maybeFocus = (element: Element | EventTarget | null) => {
73+
// Skip processing the event if the focused node is currently updating.
74+
if (this.isUpdatingFocusedNode) return;
75+
7276
this.recentlyLostAllFocus = !element;
7377
let newNode: IFocusableNode | null | undefined = null;
7478
if (element instanceof HTMLElement || element instanceof SVGElement) {
@@ -240,7 +244,23 @@ export class FocusManager {
240244
*/
241245
focusNode(focusableNode: IFocusableNode): void {
242246
this.ensureManagerIsUnlocked();
243-
if (this.focusedNode === focusableNode) return; // State is unchanged.
247+
if (!this.currentlyHoldsEphemeralFocus) {
248+
// Disable state syncing from DOM events since possible calls to focus()
249+
// below will loop a call back to focusNode().
250+
this.isUpdatingFocusedNode = true;
251+
}
252+
253+
// Double check that state wasn't desynchronized in the background. See:
254+
// https://github.com/google/blockly-keyboard-experimentation/issues/87.
255+
// This is only done for the case where the same node is being focused twice
256+
// since other cases should automatically correct (due to the rest of the
257+
// routine running as normal).
258+
const prevFocusedElement = this.focusedNode?.getFocusableElement();
259+
const hasDesyncedState = prevFocusedElement !== document.activeElement;
260+
if (this.focusedNode === focusableNode && !hasDesyncedState) {
261+
return; // State is unchanged.
262+
}
263+
244264
if (!focusableNode.canBeFocused()) {
245265
// This node can't be focused.
246266
console.warn("Trying to focus a node that can't be focused.");
@@ -292,6 +312,10 @@ export class FocusManager {
292312
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
293313
}
294314
this.updateFocusedNode(nodeToFocus);
315+
if (!this.currentlyHoldsEphemeralFocus) {
316+
// Reenable state syncing from DOM events.
317+
this.isUpdatingFocusedNode = false;
318+
}
295319
}
296320

297321
/**

tests/mocha/focus_manager_test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,91 @@ suite('FocusManager', function () {
419419
const currentNode = this.focusManager.getFocusedNode();
420420
assert.strictEqual(currentNode, this.testFocusableTree1Node2);
421421
});
422+
423+
test('restores focus when element quietly loses focus', function () {
424+
this.focusManager.registerTree(this.testFocusableTree1);
425+
this.focusManager.focusNode(this.testFocusableTree1Node1);
426+
// Remove the FocusManager's listeners to simulate not receiving a focus
427+
// event when focus is lost. This can happen in Firefox and Safari when an
428+
// element is removed and then re-added to the DOM. This is a contrived
429+
// setup to achieve the same outcome on all browsers. For context, see:
430+
// https://github.com/google/blockly-keyboard-experimentation/issues/87.
431+
for (const registeredListener of this.globalDocumentEventListeners) {
432+
const eventType = registeredListener.type;
433+
const eventListener = registeredListener.listener;
434+
document.removeEventListener(eventType, eventListener);
435+
}
436+
document.body.focus();
437+
438+
this.focusManager.focusNode(this.testFocusableTree1Node1);
439+
440+
const currentNode = this.focusManager.getFocusedNode();
441+
const currentElem = currentNode?.getFocusableElement();
442+
assert.strictEqual(currentNode, this.testFocusableTree1Node1);
443+
assert.strictEqual(document.activeElement, currentElem);
444+
});
445+
446+
test('restores focus when element and new node focused', function () {
447+
this.focusManager.registerTree(this.testFocusableTree1);
448+
this.focusManager.focusNode(this.testFocusableTree1Node1);
449+
// Remove the FocusManager's listeners to simulate not receiving a focus
450+
// event when focus is lost. This can happen in Firefox and Safari when an
451+
// element is removed and then re-added to the DOM. This is a contrived
452+
// setup to achieve the same outcome on all browsers. For context, see:
453+
// https://github.com/google/blockly-keyboard-experimentation/issues/87.
454+
for (const registeredListener of this.globalDocumentEventListeners) {
455+
const eventType = registeredListener.type;
456+
const eventListener = registeredListener.listener;
457+
document.removeEventListener(eventType, eventListener);
458+
}
459+
document.body.focus();
460+
461+
this.focusManager.focusNode(this.testFocusableTree1Node2);
462+
463+
const currentNode = this.focusManager.getFocusedNode();
464+
const currentElem = currentNode?.getFocusableElement();
465+
assert.strictEqual(currentNode, this.testFocusableTree1Node2);
466+
assert.strictEqual(document.activeElement, currentElem);
467+
});
468+
469+
test('for unfocused node calls onNodeFocus once', function () {
470+
sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus');
471+
this.focusManager.registerTree(this.testFocusableTree1);
472+
473+
this.focusManager.focusNode(this.testFocusableTree1Node1);
474+
475+
assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1);
476+
});
477+
478+
test('for previously focused node calls onNodeBlur once', function () {
479+
sinon.spy(this.testFocusableTree1Node1, 'onNodeBlur');
480+
this.focusManager.registerTree(this.testFocusableTree1);
481+
this.focusManager.focusNode(this.testFocusableTree1Node1);
482+
483+
this.focusManager.focusNode(this.testFocusableTree1Node2);
484+
485+
assert.strictEqual(this.testFocusableTree1Node1.onNodeBlur.callCount, 1);
486+
});
487+
488+
test('for unfocused tree calls onTreeFocus once', function () {
489+
sinon.spy(this.testFocusableTree1, 'onTreeFocus');
490+
this.focusManager.registerTree(this.testFocusableTree1);
491+
492+
this.focusManager.focusNode(this.testFocusableTree1Node1);
493+
494+
assert.strictEqual(this.testFocusableTree1.onTreeFocus.callCount, 1);
495+
});
496+
497+
test('for previously focused tree calls onTreeBlur once', function () {
498+
sinon.spy(this.testFocusableTree1, 'onTreeBlur');
499+
this.focusManager.registerTree(this.testFocusableTree1);
500+
this.focusManager.registerTree(this.testFocusableTree2);
501+
this.focusManager.focusNode(this.testFocusableTree1Node1);
502+
503+
this.focusManager.focusNode(this.testFocusableTree2Node1);
504+
505+
assert.strictEqual(this.testFocusableTree1.onTreeBlur.callCount, 1);
506+
});
422507
});
423508

424509
suite('getFocusManager()', function () {

0 commit comments

Comments
 (0)