Repository: nvaccess/nvda

Issue:
New focus target is not always announced
When an element is focused via JS, screen readers will usually announce it. However, this isn't always the case with NVDA, especially when the new focus target replaces the triggering element.  This is a fairly common pattern that is found on the web, especially within dynamic widgets or single page applications.

### Steps to reproduce:

1. Go to https://codepen.io/mfairchild365/pen/qBbrwXv
2. Activate the 'load' button in each example
3. Each example will 'pass' if the new focus target is announced by NVDA
4. Observe that most examples fail in NVDA 2020.4

Update: This bug is also present when focus is sent to an element that already exists in the DOM long before the trigging element is removed. This differs from my first example, where the target element is added to the DOM after the triggering element is removed. I tested this new scenario against JAWS, VO (macOS, and iOS), and NVDA. Only NVDA is silent. For example, see https://codepen.io/mfairchild365/pen/OJWydob

### Actual behavior:

Some examples fail and other pass. Details of what passed and failed can be found at the end of the codepen.

### Expected behavior:

In all examples, I'd expect the new focus target to be announced. Some screen readers do this (such as VoiceOver), and others are inconsistent (such as NVDA).

### System configuration
#### NVDA installed/portable/running from source:

Installed

#### NVDA version:

2020.4

#### Windows version:

Windows 10 20H2

#### Name and version of other software in use when reproducing the issue:

- Chrome 89
- FF 86

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your computer?

Yes

#### Have you tried any other versions of NVDA? If so, please report their behaviors.

Yes, I tried older versions of NVDA with similar results.

#### If add-ons are disabled, is your problem still occurring?

Yes

#### Did you try to run the COM registry fixing tool in NVDA menu / tools?

No
New focus target is not always announced
When an element is focused via JS, screen readers will usually announce it. However, this isn't always the case with NVDA, especially when the new focus target replaces the triggering element.  This is a fairly common pattern that is found on the web, especially within dynamic widgets or single page applications.

### Steps to reproduce:

1. Go to https://codepen.io/mfairchild365/pen/qBbrwXv
2. Activate the 'load' button in each example
3. Each example will 'pass' if the new focus target is announced by NVDA
4. Observe that most examples fail in NVDA 2020.4

Update: This bug is also present when focus is sent to an element that already exists in the DOM long before the trigging element is removed. This differs from my first example, where the target element is added to the DOM after the triggering element is removed. I tested this new scenario against JAWS, VO (macOS, and iOS), and NVDA. Only NVDA is silent. For example, see https://codepen.io/mfairchild365/pen/OJWydob

### Actual behavior:

Some examples fail and other pass. Details of what passed and failed can be found at the end of the codepen.

### Expected behavior:

In all examples, I'd expect the new focus target to be announced. Some screen readers do this (such as VoiceOver), and others are inconsistent (such as NVDA).

### System configuration
#### NVDA installed/portable/running from source:

Installed

#### NVDA version:

2020.4

#### Windows version:

Windows 10 20H2

#### Name and version of other software in use when reproducing the issue:

- Chrome 89
- FF 86

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your computer?

Yes

#### Have you tried any other versions of NVDA? If so, please report their behaviors.

Yes, I tried older versions of NVDA with similar results.

#### If add-ons are disabled, is your problem still occurring?

Yes

#### Did you try to run the COM registry fixing tool in NVDA menu / tools?

No


Discussion:

Resolving patch:
diff --git a/source/browseMode.py b/source/browseMode.py
--- a/source/browseMode.py
+++ b/source/browseMode.py
@@ -1553,6 +1553,13 @@ def event_gainFocus(self, obj, nextHandler):
 			return 
 		if not self.passThrough and self._shouldIgnoreFocus(obj):
 			return
+
+		# If the previous focus object was removed, we might hit a false positive for overlap detection.
+		# Track the previous focus target so that we can account for this scenario.
+		previousFocusObjIsDefunct = False
+		if self._lastFocusObj and controlTypes.STATE_DEFUNCT in self._lastFocusObj.states:
+			previousFocusObjIsDefunct = True
+
 		self._lastFocusObj=obj
 
 		try:
@@ -1570,7 +1577,8 @@ def event_gainFocus(self, obj, nextHandler):
 		caretInfo=self.makeTextInfo(textInfos.POSITION_CARET)
 		# Expand to one character, as isOverlapping() doesn't treat, for example, (4,4) and (4,5) as overlapping.
 		caretInfo.expand(textInfos.UNIT_CHARACTER)
-		if not self._hadFirstGainFocus or not focusInfo.isOverlapping(caretInfo):
+		isOverlapping = focusInfo.isOverlapping(caretInfo)
+		if not self._hadFirstGainFocus or not isOverlapping or (isOverlapping and previousFocusObjIsDefunct):
 			# The virtual caret is not within the focus node.
 			oldPassThrough=self.passThrough
 			passThrough = self.shouldPassThrough(obj, reason=OutputReason.FOCUS)


Test patch:
diff --git a/tests/system/robot/chromeTests.py b/tests/system/robot/chromeTests.py
--- a/tests/system/robot/chromeTests.py
+++ b/tests/system/robot/chromeTests.py
@@ -345,3 +345,37 @@ def test_ariaCheckbox_browseMode():
 		actualSpeech,
 		"Sandwich Condiments  grouping  list  with 4 items  Lettuce  check box  not checked"
 	)
+
+
+def test_i12147():
+	"""
+	New focus target should be announced if the triggering element is removed when activated.
+	"""
+	_chrome.prepareChrome(
+		f"""
+			<div>
+			  <button id='trigger0'>trigger 0</button>
+			  <h4 id='target0' tabindex='-1'>target 0</h4>
+			</div>
+			<script>
+				let trigger0 = document.querySelector('#trigger0');
+				trigger0.addEventListener('click', e => {{
+				  let focusTarget = document.querySelector('#target0');
+				  trigger0.remove();
+				  focusTarget.focus();
+				}})
+			</script>
+		"""
+	)
+	# Jump to the first button (the trigger)
+	actualSpeech = _chrome.getSpeechAfterKey("tab")
+	_asserts.strings_match(
+		actualSpeech,
+		"trigger 0  button"
+	)
+	# Activate the button, we should hear the new focus target.
+	actualSpeech = _chrome.getSpeechAfterKey("enter")
+	_asserts.strings_match(
+		actualSpeech,
+		"target 0  heading  level 4"
+	)
diff --git a/tests/system/robot/chromeTests.robot b/tests/system/robot/chromeTests.robot
--- a/tests/system/robot/chromeTests.robot
+++ b/tests/system/robot/chromeTests.robot
@@ -47,3 +47,6 @@ ARIA checkbox
 	[Documentation]	Navigate to an unchecked checkbox in reading mode.
 	[Tags]	aria-at
 	test_ariaCheckbox_browseMode
+i12147
+	[Documentation]	New focus target should be announced if the triggering element is removed when activated
+	test_i12147