Skip to content

Commit

Permalink
Improve Fragment unmounting while correctly swapping nested fragments (
Browse files Browse the repository at this point in the history
…#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
  • Loading branch information
andrewiggins committed Feb 2, 2023
1 parent e703a62 commit fc5758b
Show file tree
Hide file tree
Showing 3 changed files with 93 additions and 15 deletions.
11 changes: 11 additions & 0 deletions src/diff/children.js
Expand Up @@ -205,6 +205,17 @@ export function diffChildren(
// 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 = newParentVNode._nextDom.nextSibling;
}

unmount(oldChildren[i], oldChildren[i]);
}
}
Expand Down
28 changes: 25 additions & 3 deletions test/browser/focus.test.js
Expand Up @@ -13,6 +13,9 @@ describe('focus', () => {
/** @type {() => void} */
let rerender;

/** @type {(newState: Partial<{ before: number[]; after: number[] }>) => void} */
let setState;

/** @type {() => void} */
let prepend, append, shift, pop;

Expand All @@ -27,6 +30,8 @@ describe('focus', () => {
after: props.initialAfter || []
};

setState = newState => this.setState(newState);

prepend = () => {
const before = this.state.before;
const newValue = before[0] ? before[0] - 1 : 1;
Expand Down Expand Up @@ -56,13 +61,14 @@ describe('focus', () => {
});
};

const liHtml = this.props.as == Input ? inputStr : span;
getDynamicListHtml = () =>
div([
getDynamicListHtml = () => {
const liHtml = this.props.as == Input ? inputStr : span;
return div([
...this.state.before.map(liHtml),
'<input id="input-0" type="text">',
...this.state.after.map(liHtml)
]);
};
}

render(props, state) {
Expand Down Expand Up @@ -377,6 +383,22 @@ describe('focus', () => {
validateFocus(input, 'remove sibling before 2');
});

it('should maintain focus when removing element directly before input', () => {
render(
<DynamicList initialBefore={[0, 1]} initialAfter={[2, 3]} />,
scratch
);

let input = focusInput();
expect(scratch.innerHTML).to.equal(getDynamicListHtml());

setState({ before: [0] });
rerender();

expect(scratch.innerHTML).to.equal(getDynamicListHtml());
validateFocus(input, 'remove sibling directly before input');
});

it('should maintain focus when adding input next to the current input', () => {
render(<DynamicList as={Input} />, scratch);

Expand Down
69 changes: 57 additions & 12 deletions test/browser/fragments.test.js
Expand Up @@ -237,9 +237,7 @@ describe('Fragment', () => {
expectDomLogToBe([
'<div>.appendChild(#text)',
'<div>122.insertBefore(<div>1, <span>1)',
'<span>1.remove()',
'<div>122.appendChild(<span>2)',
'<div>122.appendChild(<span>2)'
'<span>1.remove()'
]);
});

Expand Down Expand Up @@ -2658,7 +2656,7 @@ describe('Fragment', () => {
render(<App condition={false} />, scratch);

expect(scratch.innerHTML).to.equal(div([div(1), div('A')]));
expectDomLogToBe(['<div>2.remove()', '<div>1A.appendChild(<div>A)']);
expectDomLogToBe(['<div>2.remove()']);
});

it('should efficiently unmount nested Fragment children', () => {
Expand Down Expand Up @@ -2857,13 +2855,12 @@ describe('Fragment', () => {
'<div>123AB.insertBefore(<span>1, <div>1)',
'<div>2.remove()',
'<div>3.remove()',
'<div>1.remove()',
'<div>1AB.appendChild(<div>A)',
'<div>1BA.appendChild(<div>B)'
'<div>1.remove()'
]);
});

it('should swap nested fragments correctly', () => {
/** @type {() => void} */
let swap;
class App extends Component {
constructor(props) {
Expand All @@ -2875,11 +2872,9 @@ describe('Fragment', () => {
if (this.state.first) {
return (
<Fragment>
{
<Fragment>
<p>1. Original item first paragraph</p>
</Fragment>
}
<Fragment>
<p>1. Original item first paragraph</p>
</Fragment>
<p>2. Original item second paragraph</p>
<button onClick={(swap = () => this.setState({ first: false }))}>
Click me
Expand Down Expand Up @@ -2919,4 +2914,54 @@ describe('Fragment', () => {
'<p>1. Original item first paragraph</p><p>2. Original item second paragraph</p><button>Click me</button>'
);
});

it('should efficiently unmount nested Fragment children when rerendering and reordering', () => {
/** @type {() => void} */
let toggle;

class App extends Component {
constructor(props) {
super(props);
this.state = { condition: true };
toggle = () => this.setState({ condition: !this.state.condition });
}

render() {
return this.state.condition ? (
<Fragment>
<div>1</div>
<Fragment>
<div>A</div>
<div>B</div>
</Fragment>
<div>2</div>
</Fragment>
) : (
<Fragment>
<Fragment>
<div>A</div>
</Fragment>
<div>1</div>
<div>2</div>
</Fragment>
);
}
}

clearLog();
render(<App />, scratch);
expect(scratch.innerHTML).to.equal(
[div(1), div('A'), div('B'), div(2)].join('')
);

clearLog();
toggle();
rerender();

expect(scratch.innerHTML).to.equal([div('A'), div(1), div(2)].join(''));
expectDomLogToBe([
'<div>B.remove()',
'<div>1A2.insertBefore(<div>1, <div>2)'
]);
});
});

0 comments on commit fc5758b

Please sign in to comment.