Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change in v10.11.1 causes inputs to lose focus on DOM changes #3814

Closed
1 task
jcestibariz opened this issue Nov 23, 2022 · 6 comments · Fixed by #3875
Closed
1 task

Change in v10.11.1 causes inputs to lose focus on DOM changes #3814

jcestibariz opened this issue Nov 23, 2022 · 6 comments · Fixed by #3875

Comments

@jcestibariz
Copy link

  • Check if updating to the latest Preact version resolves the issue (no)

Describe the bug
Up to preact v10.11.0 changes in the DOM did not cause a focused input to lose focus. Starting with v10.11.1 the input loses focus.

To Reproduce

Steps to reproduce the behavior:

  1. Check out https://github.com/jcestibariz/bug-preact-focus
  2. Notice preact is locked to v10.11.0 which does not contain the bug
  3. Run npm i and then npm start
  4. In the browser click on the input and press backspace
  5. Notice the last "tag" is removed but the input keeps focus.
  6. Go back to the console and press Ctrl+C to stop the server
  7. Run npm i preact@10.11.1 and then npm start
  8. Go back to the browser and repeat step 4
  9. Notice now the input loses focus
  10. You can also check with the latest version (v10.11.3 as of this moment) the bug is still present

Expected behavior
The input should not lose focus when DOM changes.

@jcestibariz
Copy link
Author

jcestibariz commented Nov 26, 2022

I ran a git bisect and looks like the commit that caused this bug is:

284a8b0d969f8ef3a3eeff0107b0104a52f774ad is the first bad commit
commit 284a8b0d969f8ef3a3eeff0107b0104a52f774ad
Author: Jovi De Croock <decroockjovi@gmail.com>
Date:   Thu Sep 29 10:42:24 2022 +0200

    remove _nextDom resetting as it collides with nested fragment switching (#3738)

 src/diff/children.js           | 11 ------
 test/browser/fragments.test.js | 78 +++++++++++++++++++++++++++++++++++++++---
 2 files changed, 73 insertions(+), 16 deletions(-)

I reverted this change on top of the current master (d4089df) and I can confirm that reverting this change fixes the bug. Since I don't have enough knowledge of preact's internals to tell @JoviDeCroock 's intention, I won't submit this revert as the solution.

@developit
Copy link
Member

Thanks for doing the bisect @jcestibariz, that's super helpful. My guess is that this is triggering a remount that isn't covered by our tests (which sometimes just assert final tree structure).

@jcestibariz
Copy link
Author

Yes, I'm pretty sure the input is removed from the DOM and added back. One of the unit tests (test/browser/fragments.test.js line 240) seems to be checking for the DOM operations queued, in the diff for that commit you can see an additional remove and two appendChild calls were added

@JoviDeCroock
Copy link
Member

JoviDeCroock commented Dec 5, 2022

Reverting it would not be the right course of action as the PR solves an issue with unmounting fragments, dissecting the issue though points at a bigger one 😅 so first off, the reproduction isn't using keys for a changing list of elements, which does hint that these remounts are intended. Going further, when we add the keys, in this reproduction, we can see that the focus is lost when we remove the first element but not the second time around which does point that we are dealing with a matching issue of or dom or vnodes.

PR for reference #3738 - we might need to tweak this to better account for the two cases.

@jcestibariz
Copy link
Author

jcestibariz commented Dec 5, 2022

I had missed the key in each "tag" span, sorry about that. I added that and added a third tag. The behaviour didn't change after adding the key, the bug is still reproducible. Notice that removing each of "bar" and "baz" tags the focus is lost. As @JoviDeCroock observed only when the last tag ("foo") is removed the focus stays on the input.

@andrewiggins
Copy link
Member

Re: our test suite - we have quite a few tests around maintaining focus on inputs when adding/removing elements around it, but it does not cover removing the element directly before the input. Thanks for finding and reporting this!

Fix incoming, hang tight :)

andrewiggins added a commit that referenced this issue Feb 2, 2023
…#3875)

## Why does #3814 fail on master?

When transitioning from the tree

```jsx
<div>
  <Fragment>
    <span>0</span>
    <span>1</span>
  </Fragment>
  <input />
</div>
```

to the tree (note `<span>1</span>` was unmounted)

```jsx
<div>
  <Fragment>
    <span>0</span>
  </Fragment>
  <input />
</div>
```

the `master` branch has a bug where it will re-append all the children after the `Fragment`. This re-appending of the `<input>` causes it to lose focus. The reason for this re-appending is that when exiting `diffChildren` for the Fragment, it's `_nextDom` pointer is set to `<span>1</span>`. Since `<span>1</span>` is unmounted it's `parentNode` is `null`. When diffing the `input` element, we hit the `oldDom.parentNode !== parentDom` condition in `placeChild` which re-appends the `<input>` tag and sets oldDom to `null` causing all siblings of `<input>` to re-append.

When diffing components/Fragments, the `_nextDom` pointer should point to where in the DOM tree the diff should continue. So when unmounting `<span>1</span>`, the Fragment's `_nextDom` should point to the `<input>` tag. The previous code in `diffChildren` removed in #3738 was intended to fix this by detecting when `_nextDom` pointed to a node that we were unmounting and reset it to it's sibling. However, that code (copied below) had a correctness bug prompting its removal (#3737). Let's look at this bug.

```js
// Remove remaining oldChildren if there are any.
for (i = oldChildrenLength; i--; ) {
	if (oldChildren[i] != null) {
		if (
			typeof newParentVNode.type == 'function' &&
			oldChildren[i]._dom != null &&
			oldChildren[i]._dom == newParentVNode._nextDom
		) {
			// If the newParentVNode.__nextDom points to a dom node that is about to
			// be unmounted, then get the next sibling of that vnode and set
			// _nextDom to it
			newParentVNode._nextDom = getDomSibling(oldParentVNode, i + 1);
		}

		unmount(oldChildren[i], oldChildren[i]);
	}
}
```

## What caused #3737?

Here is a simplified repro of the issue from #3737:

```jsx
function App() {
	const [condition] = useState(true);
	return this.state.condition ? (
		// Initial render
		<>
			<div>1</div>
			<Fragment>
				<div>A</div>
				<div>B</div>
			</Fragment>
			<div>2</div>
		</>
	) : (
		// Second render: unmount <div>B and move Fragment up
		<>
			<Fragment>
				<div>A</div>
			</Fragment>
			<div>1</div>
			<div>2</div>
		</>
	);
}
```

The first render creates the DOM `1 A B 2` (each wrapped in divs) and when changing the `condition` state, it should rerender to produce `A 1 2` but instead we get `1 A 2`. Why?

When rerendering `App` we unmount `<div>B</div>`, which is a child of a Fragment. This unmounting triggers the call to `getDomSibling` in the code above. `getDomSibling` has a line of code in it that looks like `vnode._parent._children.indexOf(vnode)`. This line of code doesn't work in this situation because when rerendering a component, we only make a shallow copy of the old VNode tree. So when a child of `oldParentVNode` (the `App` component in this case) tries to access it's parent through the `_parent` pointer (e.g. the `Fragment` parent of `<div>A</div>`), it's `_parent` pointer points to the new VNode tree which we are in progress of diffing and modifying. Ultimately, this tree mismatch (trying to access the old VNode tree but getting the in-progress new tree) causes us to get the wrong DOM pointer and leads to incorrect DOM ordering.

In summary, when unmounting `<div>B</div>`, we need `getDomSibling` to return `<div>2</div>` since that is the next DOM sibling in the old VNode tree. Instead, because our VNode pointers are mixed at this stage of diffing, `getDomSibling` doesn't work correctly and we get back the wrong DOM element.

## Why didn't other tests catch this?

Other tests only do top-level render calls (e.g. `render(<App condition={true} />)` then `render(<App condition={false} />)`) which generate brand-new VNode trees with no shared pointers. They did not test renders originating from `setState` calls which go through a different code path and reuse VNode trees which share pointers across the old and new trees.

## The fix

The initial fix for this is to replace `getDomSibling` with a call to `dom.nextSibling` to get the actual next DOM sibling. (I found a situation in which this doesn't work optimally. I'll open a separate PR for that.)

## Final thoughts

One additional thought I have here is that walking through this has given me more confidence in our approach for v11. First, we do unmounts before insertions so we don't have to do this additional DOM pointer checking. Also, by diffing backwards, we ensure that our `_next` pointers are correct when we go to search what DOM element to insert an element before.


Fixes #3814, #3737
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants