fix: Improve missing node resiliency (#8997)

## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #8994

### Proposed Changes

This removes an error that was previously thrown by `FocusManager` when attempting to focus an invalid node (such as one that's been removed from its parent).

### Reason for Changes

https://github.com/google/blockly/issues/8994#issuecomment-2855447539 goes into more detail. While this error did cover legitimately wrong cases to try and focus things (and helped to catch some real problems), fixing this 'properly' may become a leaky boat problem where we have to track down every possible asynchronous scenario that could produce such a case. One class of this is ephemeral focus which had robustness improvements itself in #8981 that, by effect, caused this issue in the first place. Holistically fixing this with enforced API contracts alone isn't simple due to the nature of how these components interact.

This change ensures that there's a sane default to fall back on if an invalid node is passed in. Note that `FocusManager` was designed specifically to disallow defocusing a node (since fallbacks can get messy and introduce unpredictable user experiences), and this sort of allows that now. However, this seems like a reasonable approach as it defaults to the behavior when focusing a tree explicitly which allows the tree to fallback to a more suitable default (such as the first item to select in the toolbox for that particular tree). In many cases this will default back to the tree's root node (such as the workspace root group) since sometimes the removed node is still the "last focused node" of the tree (and is considered valid for the purpose of determining a fallback; tree implementations could further specialize by checking whether that node is still valid).

### Test Coverage

Some new tests were added to cover this case, but more may be useful to add as part of #8910.

### Documentation

No documentation needs to be added or updated as part of this (beyond code documentation changes).

### Additional Information

This original issue was found by @RoboErikG when testing #8995. I also verified this against the keyboard navigation plugin repository.
This commit is contained in:
Ben Henning
2025-05-06 12:57:19 -07:00
committed by GitHub
parent 86c831a3fe
commit a3b3ea72f2
3 changed files with 49 additions and 13 deletions

View File

@@ -38,6 +38,7 @@ class FocusableTreeImpl {
this.nestedTrees = nestedTrees;
this.idToNodeMap = {};
this.rootNode = this.addNode(rootElement);
this.fallbackNode = null;
}
addNode(element) {
@@ -46,12 +47,16 @@ class FocusableTreeImpl {
return node;
}
removeNode(node) {
delete this.idToNodeMap[node.getFocusableElement().id];
}
getRootFocusableNode() {
return this.rootNode;
}
getRestoredFocusableNode() {
return null;
return this.fallbackNode;
}
getNestedTrees() {
@@ -385,6 +390,31 @@ suite('FocusManager', function () {
// There should be exactly 1 focus event fired from focusNode().
assert.strictEqual(focusCount, 1);
});
test('for orphaned node returns tree root by default', function () {
this.focusManager.registerTree(this.testFocusableTree1);
this.testFocusableTree1.removeNode(this.testFocusableTree1Node1);
this.focusManager.focusNode(this.testFocusableTree1Node1);
// Focusing an invalid node should fall back to the tree root when it has no restoration
// fallback node.
const currentNode = this.focusManager.getFocusedNode();
const treeRoot = this.testFocusableTree1.getRootFocusableNode();
assert.strictEqual(currentNode, treeRoot);
});
test('for orphaned node returns specified fallback node', function () {
this.focusManager.registerTree(this.testFocusableTree1);
this.testFocusableTree1.fallbackNode = this.testFocusableTree1Node2;
this.testFocusableTree1.removeNode(this.testFocusableTree1Node1);
this.focusManager.focusNode(this.testFocusableTree1Node1);
// Focusing an invalid node should fall back to the restored fallback.
const currentNode = this.focusManager.getFocusedNode();
assert.strictEqual(currentNode, this.testFocusableTree1Node2);
});
});
suite('getFocusManager()', function () {