Skip to content

Commit c426c6d

Browse files
authored
fix: Short-circuit node lookups for missing IDs (#9174)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9155 ### Proposed Changes In cases when an ID is missing for an element passed to `FocusableTreeTraverser.findFocusableNodeFor()`, always return `null`. Additionally, the new short-circuit logic exposed that `Toolbox` actually wasn't being set up correctly (that is, its root element was not being configured with a valid ID). This has been fixed. ### Reason for Changes These are cases when a valid node should never be matched (and it's technically possible to incorrectly match if an `IFocusableNode` is set up incorrectly and is providing a focusable element with an unset ID). This avoids the extra computation time of potentially calling deep into `WorkspaceSvg` and exploring all possible nodes for an ID that should never match. Note that there is a weird quirk with `null` IDs actually being the string `"null"`. This is a side effect of how `setAttribute` and attributes in general work with HTML elements. There's nothing really that can be done here, so it's now considered invalid to also have an ID of string `"null"` just to ensure the `null` case is properly short-circuited. Finally, the issue with toolbox being configured incorrectly was discovered with the introducing of a new hard failure in `FocusManager.registerTree()` when a tree with an invalid root element is registered. From testing there are no other such trees that need to be updated. A new warning was also added if `focusNode()` is used on a node with an element that has an invalid ID. This isn't a hard failure to follow the convention of other invalid `focusNode()` situations. It's much more fragile for `focusNode()` to throw than `registerTree()` since the former generally happens much earlier in a page lifecycle, and is less prone to dynamic behaviors. ### Test Coverage New tests were added to validate the various empty ID cases for `FocusableTreeTraverser.findFocusableNodeFor()`, and to validate the new error check for `FocusManager.registerTree()`. ### Documentation No new documentation should be needed. ### Additional Information Nothing to add.
1 parent 19da66c commit c426c6d

5 files changed

Lines changed: 148 additions & 4 deletions

File tree

core/focus_manager.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,15 @@ export class FocusManager {
174174
this.registeredTrees.push(
175175
new TreeRegistration(tree, rootShouldBeAutoTabbable),
176176
);
177+
const rootElement = tree.getRootFocusableNode().getFocusableElement();
178+
if (!rootElement.id || rootElement.id === 'null') {
179+
throw Error(
180+
`Attempting to register a tree with a root element that has an ` +
181+
`invalid ID: ${tree}.`,
182+
);
183+
}
177184
if (rootShouldBeAutoTabbable) {
178-
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
185+
rootElement.tabIndex = 0;
179186
}
180187
}
181188

@@ -344,13 +351,22 @@ export class FocusManager {
344351
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
345352
}
346353

354+
const focusableNodeElement = focusableNode.getFocusableElement();
355+
if (!focusableNodeElement.id || focusableNodeElement.id === 'null') {
356+
// Warn that the ID is invalid, but continue execution since an invalid ID
357+
// will result in an unmatched (null) node. Since a request to focus
358+
// something was initiated, the code below will attempt to find the next
359+
// best thing to focus, instead.
360+
console.warn('Trying to focus a node that has an invalid ID.');
361+
}
362+
347363
// Safety check for ensuring focusNode() doesn't get called for a node that
348364
// isn't actually hooked up to its parent tree correctly. This usually
349365
// happens when calls to focusNode() interleave with asynchronous clean-up
350366
// operations (which can happen due to ephemeral focus and in other cases).
351367
// Fall back to a reasonable default since there's no valid node to focus.
352368
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
353-
focusableNode.getFocusableElement(),
369+
focusableNodeElement,
354370
nextTree,
355371
);
356372
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);

core/toolbox/toolbox.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js';
4343
import * as Touch from '../touch.js';
4444
import * as aria from '../utils/aria.js';
4545
import * as dom from '../utils/dom.js';
46+
import * as idGenerator from '../utils/idgenerator.js';
4647
import {Rect} from '../utils/rect.js';
4748
import * as toolbox from '../utils/toolbox.js';
4849
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -185,6 +186,7 @@ export class Toolbox
185186
const svg = workspace.getParentSvg();
186187

187188
const container = this.createContainer_();
189+
container.id = idGenerator.getNextUniqueId();
188190

189191
this.contentsDiv_ = this.createContentsContainer_();
190192
aria.setRole(this.contentsDiv_, aria.Role.TREE);

core/utils/focusable_tree_traverser.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ export class FocusableTreeTraverser {
7979
* traversed but its nodes will never be returned here per the contract of
8080
* IFocusableTree.lookUpFocusableNode.
8181
*
82-
* The provided element must have a non-null ID that conforms to the contract
83-
* mentioned in IFocusableNode.
82+
* The provided element must have a non-null, non-empty ID that conforms to
83+
* the contract mentioned in IFocusableNode.
8484
*
8585
* @param element The HTML or SVG element being sought.
8686
* @param tree The tree under which the provided element may be a descendant.
@@ -90,6 +90,10 @@ export class FocusableTreeTraverser {
9090
element: HTMLElement | SVGElement,
9191
tree: IFocusableTree,
9292
): IFocusableNode | null {
93+
// Note that the null check is due to Element.setAttribute() converting null
94+
// to a string.
95+
if (!element.id || element.id === 'null') return null;
96+
9397
// First, match against subtrees.
9498
const subTreeMatches = tree.getNestedTrees().map((tree) => {
9599
return FocusableTreeTraverser.findFocusableNodeFor(element, tree);

tests/mocha/focus_manager_test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,54 @@ suite('FocusManager', function () {
249249
// The second register should not fail since the tree was previously unregistered.
250250
});
251251

252+
test('for tree with missing ID throws error', function () {
253+
const rootNode = this.testFocusableTree1.getRootFocusableNode();
254+
const rootElem = rootNode.getFocusableElement();
255+
const oldId = rootElem.id;
256+
rootElem.removeAttribute('id');
257+
258+
const errorMsgRegex =
259+
/Attempting to register a tree with a root element that has an invalid ID.+?/;
260+
assert.throws(
261+
() => this.focusManager.registerTree(this.testFocusableTree1),
262+
errorMsgRegex,
263+
);
264+
// Restore the ID for other tests.
265+
rootElem.id = oldId;
266+
});
267+
268+
test('for tree with null ID throws error', function () {
269+
const rootNode = this.testFocusableTree1.getRootFocusableNode();
270+
const rootElem = rootNode.getFocusableElement();
271+
const oldId = rootElem.id;
272+
rootElem.setAttribute('id', null);
273+
274+
const errorMsgRegex =
275+
/Attempting to register a tree with a root element that has an invalid ID.+?/;
276+
assert.throws(
277+
() => this.focusManager.registerTree(this.testFocusableTree1),
278+
errorMsgRegex,
279+
);
280+
// Restore the ID for other tests.
281+
rootElem.id = oldId;
282+
});
283+
284+
test('for tree with empty throws error', function () {
285+
const rootNode = this.testFocusableTree1.getRootFocusableNode();
286+
const rootElem = rootNode.getFocusableElement();
287+
const oldId = rootElem.id;
288+
rootElem.setAttribute('id', '');
289+
290+
const errorMsgRegex =
291+
/Attempting to register a tree with a root element that has an invalid ID.+?/;
292+
assert.throws(
293+
() => this.focusManager.registerTree(this.testFocusableTree1),
294+
errorMsgRegex,
295+
);
296+
// Restore the ID for other tests.
297+
rootElem.id = oldId;
298+
});
299+
252300
test('for unmanaged tree does not overwrite tab index', function () {
253301
this.focusManager.registerTree(this.testFocusableTree1, false);
254302

tests/mocha/focusable_tree_traverser_test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () {
348348
});
349349

350350
suite('findFocusableNodeFor()', function () {
351+
test('for element without ID returns null', function () {
352+
const tree = this.testFocusableTree1;
353+
const rootNode = tree.getRootFocusableNode();
354+
const rootElem = rootNode.getFocusableElement();
355+
const oldId = rootElem.id;
356+
// Normally it's not valid to miss an ID, but it can realistically happen.
357+
rootElem.removeAttribute('id');
358+
359+
const finding = FocusableTreeTraverser.findFocusableNodeFor(
360+
rootElem,
361+
tree,
362+
);
363+
// Restore the ID for other tests.
364+
rootElem.setAttribute('id', oldId);
365+
366+
assert.isNull(finding);
367+
});
368+
369+
test('for element with null ID returns null', function () {
370+
const tree = this.testFocusableTree1;
371+
const rootNode = tree.getRootFocusableNode();
372+
const rootElem = rootNode.getFocusableElement();
373+
const oldId = rootElem.id;
374+
// Normally it's not valid to miss an ID, but it can realistically happen.
375+
rootElem.setAttribute('id', null);
376+
377+
const finding = FocusableTreeTraverser.findFocusableNodeFor(
378+
rootElem,
379+
tree,
380+
);
381+
// Restore the ID for other tests.
382+
rootElem.setAttribute('id', oldId);
383+
384+
assert.isNull(finding);
385+
});
386+
387+
test('for element with null ID string returns null', function () {
388+
const tree = this.testFocusableTree1;
389+
const rootNode = tree.getRootFocusableNode();
390+
const rootElem = rootNode.getFocusableElement();
391+
const oldId = rootElem.id;
392+
// This is a quirky version of the null variety above that's actually
393+
// functionallity equivalent (since 'null' is converted to a string).
394+
rootElem.setAttribute('id', 'null');
395+
396+
const finding = FocusableTreeTraverser.findFocusableNodeFor(
397+
rootElem,
398+
tree,
399+
);
400+
// Restore the ID for other tests.
401+
rootElem.setAttribute('id', oldId);
402+
403+
assert.isNull(finding);
404+
});
405+
406+
test('for element with empty ID returns null', function () {
407+
const tree = this.testFocusableTree1;
408+
const rootNode = tree.getRootFocusableNode();
409+
const rootElem = rootNode.getFocusableElement();
410+
const oldId = rootElem.id;
411+
// An empty ID is invalid since it will potentially conflict with other
412+
// elements, and element IDs must be unique for focus management.
413+
rootElem.setAttribute('id', '');
414+
415+
const finding = FocusableTreeTraverser.findFocusableNodeFor(
416+
rootElem,
417+
tree,
418+
);
419+
// Restore the ID for other tests.
420+
rootElem.setAttribute('id', oldId);
421+
422+
assert.isNull(finding);
423+
});
424+
351425
test('for root element returns root', function () {
352426
const tree = this.testFocusableTree1;
353427
const rootNode = tree.getRootFocusableNode();

0 commit comments

Comments
 (0)