Skip to content

Commit 8d60bd4

Browse files
authored
[Shallow] Implement setState for Hooks and remount on type change (#15120)
* Throw away old shallow renderer state on type change This worked in function components but was broken for classes. It incorrectly retained the old instance even if the type was different. * Remove _previousComponentIdentity We only needed this because we didn't correctly reset based on type. Now we do so this can go away. * Use _reset when unmounting * Use arbitrary componentIdentity There was no particular reason it was set to element.type. We just wanted to check if something is a render phase update. * Support Hook state updates in shallow renderer
1 parent 035e4cf commit 8d60bd4

File tree

3 files changed

+274
-69
lines changed

3 files changed

+274
-69
lines changed

packages/react-test-renderer/src/ReactShallowRenderer.js

Lines changed: 66 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type Update<A> = {
3131
};
3232

3333
type UpdateQueue<A> = {
34-
last: Update<A> | null,
34+
first: Update<A> | null,
3535
dispatch: any,
3636
};
3737

@@ -178,6 +178,10 @@ class ReactShallowRenderer {
178178
};
179179

180180
constructor() {
181+
this._reset();
182+
}
183+
184+
_reset() {
181185
this._context = null;
182186
this._element = null;
183187
this._instance = null;
@@ -192,9 +196,7 @@ class ReactShallowRenderer {
192196
this._isReRender = false;
193197
this._didScheduleRenderPhaseUpdate = false;
194198
this._renderPhaseUpdates = null;
195-
this._currentlyRenderingComponent = null;
196199
this._numberOfReRenders = 0;
197-
this._previousComponentIdentity = null;
198200
}
199201

200202
_context: null | Object;
@@ -208,16 +210,14 @@ class ReactShallowRenderer {
208210
_dispatcher: DispatcherType;
209211
_workInProgressHook: null | Hook;
210212
_firstWorkInProgressHook: null | Hook;
211-
_currentlyRenderingComponent: null | Object;
212-
_previousComponentIdentity: null | Object;
213213
_renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null;
214214
_isReRender: boolean;
215215
_didScheduleRenderPhaseUpdate: boolean;
216216
_numberOfReRenders: number;
217217

218218
_validateCurrentlyRenderingComponent() {
219219
invariant(
220-
this._currentlyRenderingComponent !== null,
220+
this._rendering && !this._instance,
221221
'Hooks can only be called inside the body of a function component. ' +
222222
'(https://fb.me/react-invalid-hook-call)',
223223
);
@@ -232,33 +232,44 @@ class ReactShallowRenderer {
232232
this._validateCurrentlyRenderingComponent();
233233
this._createWorkInProgressHook();
234234
const workInProgressHook: Hook = (this._workInProgressHook: any);
235+
235236
if (this._isReRender) {
236-
// This is a re-render. Apply the new render phase updates to the previous
237-
// current hook.
237+
// This is a re-render.
238238
const queue: UpdateQueue<A> = (workInProgressHook.queue: any);
239239
const dispatch: Dispatch<A> = (queue.dispatch: any);
240-
if (this._renderPhaseUpdates !== null) {
241-
// Render phase updates are stored in a map of queue -> linked list
242-
const firstRenderPhaseUpdate = this._renderPhaseUpdates.get(queue);
243-
if (firstRenderPhaseUpdate !== undefined) {
244-
(this._renderPhaseUpdates: any).delete(queue);
245-
let newState = workInProgressHook.memoizedState;
246-
let update = firstRenderPhaseUpdate;
247-
do {
248-
// Process this render phase update. We don't have to check the
249-
// priority because it will always be the same as the current
250-
// render's.
251-
const action = update.action;
252-
newState = reducer(newState, action);
253-
update = update.next;
254-
} while (update !== null);
255-
256-
workInProgressHook.memoizedState = newState;
257-
258-
return [newState, dispatch];
240+
if (this._numberOfReRenders > 0) {
241+
// Apply the new render phase updates to the previous current hook.
242+
if (this._renderPhaseUpdates !== null) {
243+
// Render phase updates are stored in a map of queue -> linked list
244+
const firstRenderPhaseUpdate = this._renderPhaseUpdates.get(queue);
245+
if (firstRenderPhaseUpdate !== undefined) {
246+
(this._renderPhaseUpdates: any).delete(queue);
247+
let newState = workInProgressHook.memoizedState;
248+
let update = firstRenderPhaseUpdate;
249+
do {
250+
const action = update.action;
251+
newState = reducer(newState, action);
252+
update = update.next;
253+
} while (update !== null);
254+
workInProgressHook.memoizedState = newState;
255+
return [newState, dispatch];
256+
}
259257
}
258+
return [workInProgressHook.memoizedState, dispatch];
260259
}
261-
return [workInProgressHook.memoizedState, dispatch];
260+
// Process updates outside of render
261+
let newState = workInProgressHook.memoizedState;
262+
let update = queue.first;
263+
if (update !== null) {
264+
do {
265+
const action = update.action;
266+
newState = reducer(newState, action);
267+
update = update.next;
268+
} while (update !== null);
269+
queue.first = null;
270+
workInProgressHook.memoizedState = newState;
271+
}
272+
return [newState, dispatch];
262273
} else {
263274
let initialState;
264275
if (reducer === basicStateReducer) {
@@ -273,16 +284,12 @@ class ReactShallowRenderer {
273284
}
274285
workInProgressHook.memoizedState = initialState;
275286
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
276-
last: null,
287+
first: null,
277288
dispatch: null,
278289
});
279290
const dispatch: Dispatch<
280291
A,
281-
> = (queue.dispatch = (this._dispatchAction.bind(
282-
this,
283-
(this._currentlyRenderingComponent: any),
284-
queue,
285-
): any));
292+
> = (queue.dispatch = (this._dispatchAction.bind(this, queue): any));
286293
return [workInProgressHook.memoizedState, dispatch];
287294
}
288295
};
@@ -373,18 +380,14 @@ class ReactShallowRenderer {
373380
};
374381
}
375382

376-
_dispatchAction<A>(
377-
componentIdentity: Object,
378-
queue: UpdateQueue<A>,
379-
action: A,
380-
) {
383+
_dispatchAction<A>(queue: UpdateQueue<A>, action: A) {
381384
invariant(
382385
this._numberOfReRenders < RE_RENDER_LIMIT,
383386
'Too many re-renders. React limits the number of renders to prevent ' +
384387
'an infinite loop.',
385388
);
386389

387-
if (componentIdentity === this._currentlyRenderingComponent) {
390+
if (this._rendering) {
388391
// This is a render phase update. Stash it in a lazily-created map of
389392
// queue -> linked list of updates. After this render pass, we'll restart
390393
// and apply the stashed updates on top of the work-in-progress hook.
@@ -409,9 +412,24 @@ class ReactShallowRenderer {
409412
lastRenderPhaseUpdate.next = update;
410413
}
411414
} else {
412-
// This means an update has happened after the function component has
413-
// returned. On the server this is a no-op. In React Fiber, the update
414-
// would be scheduled for a future render.
415+
const update: Update<A> = {
416+
action,
417+
next: null,
418+
};
419+
420+
// Append the update to the end of the list.
421+
let last = queue.first;
422+
if (last === null) {
423+
queue.first = update;
424+
} else {
425+
while (last.next !== null) {
426+
last = last.next;
427+
}
428+
last.next = update;
429+
}
430+
431+
// Re-render now.
432+
this.render(this._element, this._context);
415433
}
416434
}
417435

@@ -441,17 +459,6 @@ class ReactShallowRenderer {
441459
return this._workInProgressHook;
442460
}
443461

444-
_prepareToUseHooks(componentIdentity: Object): void {
445-
if (
446-
this._previousComponentIdentity !== null &&
447-
this._previousComponentIdentity !== componentIdentity
448-
) {
449-
this._firstWorkInProgressHook = null;
450-
}
451-
this._currentlyRenderingComponent = componentIdentity;
452-
this._previousComponentIdentity = componentIdentity;
453-
}
454-
455462
_finishHooks(element: ReactElement, context: null | Object) {
456463
if (this._didScheduleRenderPhaseUpdate) {
457464
// Updates were scheduled during the render phase. They are stored in
@@ -466,7 +473,6 @@ class ReactShallowRenderer {
466473
this._rendering = false;
467474
this.render(element, context);
468475
} else {
469-
this._currentlyRenderingComponent = null;
470476
this._workInProgressHook = null;
471477
this._renderPhaseUpdates = null;
472478
this._numberOfReRenders = 0;
@@ -514,6 +520,9 @@ class ReactShallowRenderer {
514520
if (this._rendering) {
515521
return;
516522
}
523+
if (this._element != null && this._element.type !== element.type) {
524+
this._reset();
525+
}
517526

518527
const elementType = isMemo(element.type) ? element.type.type : element.type;
519528
const previousElement = this._element;
@@ -574,11 +583,7 @@ class ReactShallowRenderer {
574583
this._mountClassComponent(elementType, element, this._context);
575584
} else {
576585
let shouldRender = true;
577-
if (
578-
isMemo(element.type) &&
579-
elementType === this._previousComponentIdentity &&
580-
previousElement !== null
581-
) {
586+
if (isMemo(element.type) && previousElement !== null) {
582587
// This is a Memo component that is being re-rendered.
583588
const compare = element.type.compare || shallowEqual;
584589
if (compare(previousElement.props, element.props)) {
@@ -588,7 +593,6 @@ class ReactShallowRenderer {
588593
if (shouldRender) {
589594
const prevDispatcher = ReactCurrentDispatcher.current;
590595
ReactCurrentDispatcher.current = this._dispatcher;
591-
this._prepareToUseHooks(elementType);
592596
try {
593597
// elementType could still be a ForwardRef if it was
594598
// nested inside Memo.
@@ -626,14 +630,7 @@ class ReactShallowRenderer {
626630
this._instance.componentWillUnmount();
627631
}
628632
}
629-
630-
this._firstWorkInProgressHook = null;
631-
this._previousComponentIdentity = null;
632-
this._context = null;
633-
this._element = null;
634-
this._newState = null;
635-
this._rendered = null;
636-
this._instance = null;
633+
this._reset();
637634
}
638635

639636
_mountClassComponent(

packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,4 +1565,46 @@ describe('ReactShallowRenderer', () => {
15651565
'forwardRef requires a render function but was given object.',
15661566
);
15671567
});
1568+
1569+
it('should let you change type', () => {
1570+
function Foo({prop}) {
1571+
return <div>Foo {prop}</div>;
1572+
}
1573+
function Bar({prop}) {
1574+
return <div>Bar {prop}</div>;
1575+
}
1576+
1577+
const shallowRenderer = createRenderer();
1578+
shallowRenderer.render(<Foo prop="foo1" />);
1579+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo1'}</div>);
1580+
shallowRenderer.render(<Foo prop="foo2" />);
1581+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo2'}</div>);
1582+
shallowRenderer.render(<Bar prop="bar1" />);
1583+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar1'}</div>);
1584+
shallowRenderer.render(<Bar prop="bar2" />);
1585+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar2'}</div>);
1586+
});
1587+
1588+
it('should let you change class type', () => {
1589+
class Foo extends React.Component {
1590+
render() {
1591+
return <div>Foo {this.props.prop}</div>;
1592+
}
1593+
}
1594+
class Bar extends React.Component {
1595+
render() {
1596+
return <div>Bar {this.props.prop}</div>;
1597+
}
1598+
}
1599+
1600+
const shallowRenderer = createRenderer();
1601+
shallowRenderer.render(<Foo prop="foo1" />);
1602+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo1'}</div>);
1603+
shallowRenderer.render(<Foo prop="foo2" />);
1604+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo2'}</div>);
1605+
shallowRenderer.render(<Bar prop="bar1" />);
1606+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar1'}</div>);
1607+
shallowRenderer.render(<Bar prop="bar2" />);
1608+
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar2'}</div>);
1609+
});
15681610
});

0 commit comments

Comments
 (0)