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

@@ -227,7 +227,7 @@ export class FocusManager {
} }
/** /**
* Focuses DOM input on the selected node, and marks it as actively focused. * Focuses DOM input on the specified node, and marks it as actively focused.
* *
* Any previously focused node will be updated to be passively highlighted (if * Any previously focused node will be updated to be passively highlighted (if
* it's in a different focusable tree) or blurred (if it's in the same one). * it's in a different focusable tree) or blurred (if it's in the same one).
@@ -244,17 +244,20 @@ export class FocusManager {
} }
// Safety check for ensuring focusNode() doesn't get called for a node that // Safety check for ensuring focusNode() doesn't get called for a node that
// isn't actually hooked up to its parent tree correctly (since this can // isn't actually hooked up to its parent tree correctly. This usually
// cause weird inconsistencies). // happens when calls to focusNode() interleave with asynchronous clean-up
// operations (which can happen due to ephemeral focus and in other cases).
// Fall back to a reasonable default since there's no valid node to focus.
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
focusableNode.getFocusableElement(), focusableNode.getFocusableElement(),
nextTree, nextTree,
); );
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
let nodeToFocus = focusableNode;
if (matchedNode !== focusableNode) { if (matchedNode !== focusableNode) {
throw Error( const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree);
`Attempting to focus node which isn't recognized by its parent tree: ` + const rootFallback = nextTree.getRootFocusableNode();
`${focusableNode}.`, nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback;
);
} }
const prevNode = this.focusedNode; const prevNode = this.focusedNode;
@@ -264,7 +267,6 @@ export class FocusManager {
} }
// If there's a focused node in the new node's tree, ensure it's reset. // If there's a focused node in the new node's tree, ensure it's reset.
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
const nextTreeRoot = nextTree.getRootFocusableNode(); const nextTreeRoot = nextTree.getRootFocusableNode();
if (prevNodeNextTree) { if (prevNodeNextTree) {
this.removeHighlight(prevNodeNextTree); this.removeHighlight(prevNodeNextTree);
@@ -272,19 +274,19 @@ export class FocusManager {
// For caution, ensure that the root is always reset since getFocusedNode() // For caution, ensure that the root is always reset since getFocusedNode()
// is expected to return null if the root was highlighted, if the root is // is expected to return null if the root was highlighted, if the root is
// not the node now being set to active. // not the node now being set to active.
if (nextTreeRoot !== focusableNode) { if (nextTreeRoot !== nodeToFocus) {
this.removeHighlight(nextTreeRoot); this.removeHighlight(nextTreeRoot);
} }
if (!this.currentlyHoldsEphemeralFocus) { if (!this.currentlyHoldsEphemeralFocus) {
// Only change the actively focused node if ephemeral state isn't held. // Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(focusableNode, prevTree ?? null); this.activelyFocusNode(nodeToFocus, prevTree ?? null);
} }
this.updateFocusedNode(focusableNode); this.updateFocusedNode(nodeToFocus);
} }
/** /**
* Ephemerally captures focus for a selected element until the returned lambda * Ephemerally captures focus for a specific element until the returned lambda
* is called. This is expected to be especially useful for ephemeral UI flows * is called. This is expected to be especially useful for ephemeral UI flows
* like dialogs. * like dialogs.
* *

View File

@@ -52,6 +52,10 @@ export interface IFocusableTree {
* bypass this method. * bypass this method.
* 3. The default behavior (i.e. returning null here) involves either * 3. The default behavior (i.e. returning null here) involves either
* restoring the previous node (previousNode) or focusing the tree's root. * restoring the previous node (previousNode) or focusing the tree's root.
* 4. The provided node may sometimes no longer be valid, such as in the case
* an attempt is made to focus a node that has been recently removed from
* its parent tree. Implementations can check for the validity of the node
* in order to specialize the node to which focus should fall back.
* *
* This method is largely intended to provide tree implementations with the * This method is largely intended to provide tree implementations with the
* means of specifying a better default node than their root. * means of specifying a better default node than their root.

View File

@@ -38,6 +38,7 @@ class FocusableTreeImpl {
this.nestedTrees = nestedTrees; this.nestedTrees = nestedTrees;
this.idToNodeMap = {}; this.idToNodeMap = {};
this.rootNode = this.addNode(rootElement); this.rootNode = this.addNode(rootElement);
this.fallbackNode = null;
} }
addNode(element) { addNode(element) {
@@ -46,12 +47,16 @@ class FocusableTreeImpl {
return node; return node;
} }
removeNode(node) {
delete this.idToNodeMap[node.getFocusableElement().id];
}
getRootFocusableNode() { getRootFocusableNode() {
return this.rootNode; return this.rootNode;
} }
getRestoredFocusableNode() { getRestoredFocusableNode() {
return null; return this.fallbackNode;
} }
getNestedTrees() { getNestedTrees() {
@@ -385,6 +390,31 @@ suite('FocusManager', function () {
// There should be exactly 1 focus event fired from focusNode(). // There should be exactly 1 focus event fired from focusNode().
assert.strictEqual(focusCount, 1); 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 () { suite('getFocusManager()', function () {