Skip to content

Commit 1a3b8b5

Browse files
authored
Fix error recovery for partially rendered subtrees (#5120)
When a component throws after `diffChildren()` has already started, its `newVNode._children` may contain partially mounted children. The error recovery path always replaced those children with `oldVNode._children`, which can be `undefined` during initial mount. That loses the partially rendered subtree, so children that queued updates are not unmounted and keep their _parentDom. A later queued update can then call `getDomSibling()` through an ancestor whose `_children` is undefined and crash. Only restore old children when the new vnode has no children yet, and discard commit/ref queue entries produced by the failed partial render so lifecycles from the aborted subtree are not committed.
1 parent 67f0290 commit 1a3b8b5

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

src/diff/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function diff(
7777
if ((tmp = options._diff)) tmp(newVNode);
7878

7979
outer: if (typeof newType == 'function') {
80+
let oldCommitQueueLength = commitQueue.length;
8081
try {
8182
let c, isNew, oldProps, oldState, snapshot, clearProcessingException;
8283
let newProps = newVNode.props;
@@ -282,6 +283,10 @@ export function diff(
282283
c._pendingError = c._processingException = NULL;
283284
}
284285
} catch (e) {
286+
// We remove any componentDidMount, ...
287+
// that have been invalidated by us
288+
// intercepting the error.
289+
commitQueue.length = oldCommitQueueLength;
285290
newVNode._original = NULL;
286291
// if hydrating or creating initial tree, bailout preserves DOM:
287292
if (isHydrating || excessDomChildren != NULL) {
@@ -308,7 +313,9 @@ export function diff(
308313
}
309314
} else {
310315
newVNode._dom = oldVNode._dom;
311-
newVNode._children = oldVNode._children;
316+
if (!newVNode._children && oldVNode._children) {
317+
newVNode._children = oldVNode._children;
318+
}
312319
if (!e.then) markAsForce(newVNode);
313320
}
314321
options._catchError(e, newVNode, oldVNode);

test/browser/lifecycles/componentDidCatch.test.jsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,108 @@ describe('Lifecycle methods', () => {
155155
);
156156
});
157157

158+
it('should discard commit callbacks from a partially rendered subtree', () => {
159+
let componentDidMount = sinon.spy();
160+
161+
class NullBoundary extends Component {
162+
componentDidCatch(error) {
163+
this.setState({ error });
164+
}
165+
166+
render() {
167+
return this.state.error ? null : this.props.children;
168+
}
169+
}
170+
171+
class MountingChild extends Component {
172+
componentDidMount() {
173+
componentDidMount();
174+
}
175+
176+
render() {
177+
return null;
178+
}
179+
}
180+
181+
function Parent() {
182+
return [
183+
<MountingChild />,
184+
// Throw from diffing after MountingChild has queued its mount
185+
// callback, but before Parent has completed rendering.
186+
createElement('div', { 'bad name': 'x' })
187+
];
188+
}
189+
190+
render(
191+
<NullBoundary>
192+
<Parent />
193+
</NullBoundary>,
194+
scratch
195+
);
196+
197+
expect(componentDidMount).not.to.have.been.called;
198+
rerender();
199+
expect(componentDidMount).not.to.have.been.called;
200+
expect(scratch.innerHTML).to.equal('');
201+
});
202+
203+
it('should clean up pending updates in a partially rendered subtree when rendering null', () => {
204+
let child;
205+
let childRenders = 0;
206+
let componentDidMount = sinon.spy();
207+
208+
class NullBoundary extends Component {
209+
componentDidCatch(error) {
210+
this.setState({ error });
211+
}
212+
213+
render() {
214+
return this.state.error ? null : this.props.children;
215+
}
216+
}
217+
218+
class NullChild extends Component {
219+
constructor(props) {
220+
super(props);
221+
child = this;
222+
}
223+
224+
componentDidMount() {
225+
componentDidMount();
226+
}
227+
228+
render() {
229+
childRenders++;
230+
if (!this.state.tick) this.setState({ tick: 1 });
231+
return null;
232+
}
233+
}
234+
235+
function Parent() {
236+
return [
237+
<NullChild />,
238+
// Throw from diffing after NullChild has been diffed, but before
239+
// Parent has completed rendering its children.
240+
createElement('div', { 'bad name': 'x' })
241+
];
242+
}
243+
244+
render(
245+
<NullBoundary>
246+
<Parent />
247+
</NullBoundary>,
248+
scratch
249+
);
250+
251+
expect(childRenders).to.equal(1);
252+
expect(scratch.innerHTML).to.equal('');
253+
expect(() => rerender()).not.to.throw();
254+
expect(childRenders).to.equal(1);
255+
expect(componentDidMount).not.to.have.been.called;
256+
expect(child._parentDom).to.equal(null);
257+
expect(scratch.innerHTML).to.equal('');
258+
});
259+
158260
it('should be called when child fails in componentDidMount', () => {
159261
ThrowErr.prototype.componentDidMount = throwExpectedError;
160262

0 commit comments

Comments
 (0)