@samitouri / QOS-React / commits / 8d60bd4dc2

[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

Dan Abramov committed Mar 15, 2019 at 22:30 UTC 8d60bd4dc2f7128c3de53474db50df000b14f677
3 files changed +274 -69
packages/react-test-renderer/src/ReactShallowRenderer.js
+66 -69
@@ -31,7 +31,7 @@ type Update<A> = {
31 };
32
33 type UpdateQueue<A> = {
34 - last: Update<A> | null,
34 + first: Update<A> | null,
35 dispatch: any,
36 };
37
@@ -178,6 +178,10 @@ class ReactShallowRenderer {
178 };
179
180 constructor() {
181 + this._reset();
182 + }
183 +
184 + _reset() {
185 this._context = null;
186 this._element = null;
187 this._instance = null;
@@ -192,9 +196,7 @@ class ReactShallowRenderer {
196 this._isReRender = false;
197 this._didScheduleRenderPhaseUpdate = false;
198 this._renderPhaseUpdates = null;
195 - this._currentlyRenderingComponent = null;
199 this._numberOfReRenders = 0;
197 - this._previousComponentIdentity = null;
200 }
201
202 _context: null | Object;
@@ -208,8 +210,6 @@ class ReactShallowRenderer {
210 _dispatcher: DispatcherType;
211 _workInProgressHook: null | Hook;
212 _firstWorkInProgressHook: null | Hook;
211 - _currentlyRenderingComponent: null | Object;
212 - _previousComponentIdentity: null | Object;
213 _renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null;
214 _isReRender: boolean;
215 _didScheduleRenderPhaseUpdate: boolean;
@@ -217,7 +217,7 @@ class ReactShallowRenderer {
217
218 _validateCurrentlyRenderingComponent() {
219 invariant(
220 - this._currentlyRenderingComponent !== null,
220 + this._rendering && !this._instance,
221 'Hooks can only be called inside the body of a function component. ' +
222 '(https://fb.me/react-invalid-hook-call)',
223 );
@@ -232,33 +232,44 @@ class ReactShallowRenderer {
232 this._validateCurrentlyRenderingComponent();
233 this._createWorkInProgressHook();
234 const workInProgressHook: Hook = (this._workInProgressHook: any);
235 +
236 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.
238 const queue: UpdateQueue<A> = (workInProgressHook.queue: any);
239 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 + }
257 }
258 + return [workInProgressHook.memoizedState, dispatch];
259 }
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];
273 } else {
274 let initialState;
275 if (reducer === basicStateReducer) {
@@ -273,16 +284,12 @@ class ReactShallowRenderer {
284 }
285 workInProgressHook.memoizedState = initialState;
286 const queue: UpdateQueue<A> = (workInProgressHook.queue = {
276 - last: null,
287 + first: null,
288 dispatch: null,
289 });
290 const dispatch: Dispatch<
291 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));
293 return [workInProgressHook.memoizedState, dispatch];
294 }
295 };
@@ -373,18 +380,14 @@ class ReactShallowRenderer {
380 };
381 }
382
376 - _dispatchAction<A>(
377 - componentIdentity: Object,
378 - queue: UpdateQueue<A>,
379 - action: A,
380 - ) {
383 + _dispatchAction<A>(queue: UpdateQueue<A>, action: A) {
384 invariant(
385 this._numberOfReRenders < RE_RENDER_LIMIT,
386 'Too many re-renders. React limits the number of renders to prevent ' +
387 'an infinite loop.',
388 );
389
387 - if (componentIdentity === this._currentlyRenderingComponent) {
390 + if (this._rendering) {
391 // This is a render phase update. Stash it in a lazily-created map of
392 // queue -> linked list of updates. After this render pass, we'll restart
393 // and apply the stashed updates on top of the work-in-progress hook.
@@ -409,9 +412,24 @@ class ReactShallowRenderer {
412 lastRenderPhaseUpdate.next = update;
413 }
414 } 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);
433 }
434 }
435
@@ -441,17 +459,6 @@ class ReactShallowRenderer {
459 return this._workInProgressHook;
460 }
461
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 -
462 _finishHooks(element: ReactElement, context: null | Object) {
463 if (this._didScheduleRenderPhaseUpdate) {
464 // Updates were scheduled during the render phase. They are stored in
@@ -466,7 +473,6 @@ class ReactShallowRenderer {
473 this._rendering = false;
474 this.render(element, context);
475 } else {
469 - this._currentlyRenderingComponent = null;
476 this._workInProgressHook = null;
477 this._renderPhaseUpdates = null;
478 this._numberOfReRenders = 0;
@@ -514,6 +520,9 @@ class ReactShallowRenderer {
520 if (this._rendering) {
521 return;
522 }
523 + if (this._element != null && this._element.type !== element.type) {
524 + this._reset();
525 + }
526
527 const elementType = isMemo(element.type) ? element.type.type : element.type;
528 const previousElement = this._element;
@@ -574,11 +583,7 @@ class ReactShallowRenderer {
583 this._mountClassComponent(elementType, element, this._context);
584 } else {
585 let shouldRender = true;
577 - if (
578 - isMemo(element.type) &&
579 - elementType === this._previousComponentIdentity &&
580 - previousElement !== null
581 - ) {
586 + if (isMemo(element.type) && previousElement !== null) {
587 // This is a Memo component that is being re-rendered.
588 const compare = element.type.compare || shallowEqual;
589 if (compare(previousElement.props, element.props)) {
@@ -588,7 +593,6 @@ class ReactShallowRenderer {
593 if (shouldRender) {
594 const prevDispatcher = ReactCurrentDispatcher.current;
595 ReactCurrentDispatcher.current = this._dispatcher;
591 - this._prepareToUseHooks(elementType);
596 try {
597 // elementType could still be a ForwardRef if it was
598 // nested inside Memo.
@@ -626,14 +630,7 @@ class ReactShallowRenderer {
630 this._instance.componentWillUnmount();
631 }
632 }
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();
634 }
635
636 _mountClassComponent(
packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js
+42
@@ -1565,4 +1565,46 @@ describe('ReactShallowRenderer', () => {
1565 'forwardRef requires a render function but was given object.',
1566 );
1567 });
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 + });
1610 });
packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js
+166
@@ -90,6 +90,61 @@ describe('ReactShallowRenderer with hooks', () => {
90 );
91 });
92
93 + it('should work with updating a derived value from useState', () => {
94 + let _updateName;
95 +
96 + function SomeComponent({defaultName}) {
97 + const [name, updateName] = React.useState(defaultName);
98 + const [prevName, updatePrevName] = React.useState(defaultName);
99 + const [letter, updateLetter] = React.useState(name[0]);
100 +
101 + _updateName = updateName;
102 +
103 + if (name !== prevName) {
104 + updatePrevName(name);
105 + updateLetter(name[0]);
106 + }
107 +
108 + return (
109 + <div>
110 + <p>
111 + Your name is: <span>{name + ' (' + letter + ')'}</span>
112 + </p>
113 + </div>
114 + );
115 + }
116 +
117 + const shallowRenderer = createRenderer();
118 + let result = shallowRenderer.render(
119 + <SomeComponent defaultName={'Sophie'} />,
120 + );
121 + expect(result).toEqual(
122 + <div>
123 + <p>
124 + Your name is: <span>Sophie (S)</span>
125 + </p>
126 + </div>,
127 + );
128 +
129 + result = shallowRenderer.render(<SomeComponent defaultName={'Dan'} />);
130 + expect(result).toEqual(
131 + <div>
132 + <p>
133 + Your name is: <span>Sophie (S)</span>
134 + </p>
135 + </div>,
136 + );
137 +
138 + _updateName('Dan');
139 + expect(shallowRenderer.getRenderOutput()).toEqual(
140 + <div>
141 + <p>
142 + Your name is: <span>Dan (D)</span>
143 + </p>
144 + </div>,
145 + );
146 + });
147 +
148 it('should work with useReducer', () => {
149 function reducer(state, action) {
150 switch (action.type) {
@@ -322,4 +377,115 @@ describe('ReactShallowRenderer with hooks', () => {
377
378 expect(firstResult).toEqual(secondResult);
379 });
380 +
381 + it('should update a value from useState outside the render', () => {
382 + let _dispatch;
383 +
384 + function SomeComponent({defaultName}) {
385 + const [count, dispatch] = React.useReducer(
386 + (s, a) => (a === 'inc' ? s + 1 : s),
387 + 0,
388 + );
389 + const [name, updateName] = React.useState(defaultName);
390 + _dispatch = () => dispatch('inc');
391 +
392 + return (
393 + <div onClick={() => updateName('Dan')}>
394 + <p>
395 + Your name is: <span>{name}</span> ({count})
396 + </p>
397 + </div>
398 + );
399 + }
400 +
401 + const shallowRenderer = createRenderer();
402 + const element = <SomeComponent defaultName={'Dominic'} />;
403 + const result = shallowRenderer.render(element);
404 + expect(result.props.children).toEqual(
405 + <p>
406 + Your name is: <span>Dominic</span> ({0})
407 + </p>,
408 + );
409 +
410 + result.props.onClick();
411 + let updated = shallowRenderer.render(element);
412 + expect(updated.props.children).toEqual(
413 + <p>
414 + Your name is: <span>Dan</span> ({0})
415 + </p>,
416 + );
417 +
418 + _dispatch('foo');
419 + updated = shallowRenderer.render(element);
420 + expect(updated.props.children).toEqual(
421 + <p>
422 + Your name is: <span>Dan</span> ({1})
423 + </p>,
424 + );
425 +
426 + _dispatch('inc');
427 + updated = shallowRenderer.render(element);
428 + expect(updated.props.children).toEqual(
429 + <p>
430 + Your name is: <span>Dan</span> ({2})
431 + </p>,
432 + );
433 + });
434 +
435 + it('should ignore a foreign update outside the render', () => {
436 + let _updateCountForFirstRender;
437 +
438 + function SomeComponent() {
439 + const [count, updateCount] = React.useState(0);
440 + if (!_updateCountForFirstRender) {
441 + _updateCountForFirstRender = updateCount;
442 + }
443 + return count;
444 + }
445 +
446 + const shallowRenderer = createRenderer();
447 + const element = <SomeComponent />;
448 + let result = shallowRenderer.render(element);
449 + expect(result).toEqual(0);
450 + _updateCountForFirstRender(1);
451 + result = shallowRenderer.render(element);
452 + expect(result).toEqual(1);
453 +
454 + shallowRenderer.unmount();
455 + result = shallowRenderer.render(element);
456 + expect(result).toEqual(0);
457 + _updateCountForFirstRender(1); // Should be ignored.
458 + result = shallowRenderer.render(element);
459 + expect(result).toEqual(0);
460 + });
461 +
462 + it('should not forget render phase updates', () => {
463 + let _updateCount;
464 +
465 + function SomeComponent() {
466 + const [count, updateCount] = React.useState(0);
467 + _updateCount = updateCount;
468 + if (count < 5) {
469 + updateCount(x => x + 1);
470 + }
471 + return count;
472 + }
473 +
474 + const shallowRenderer = createRenderer();
475 + const element = <SomeComponent />;
476 + let result = shallowRenderer.render(element);
477 + expect(result).toEqual(5);
478 +
479 + _updateCount(10);
480 + result = shallowRenderer.render(element);
481 + expect(result).toEqual(10);
482 +
483 + _updateCount(x => x + 1);
484 + result = shallowRenderer.render(element);
485 + expect(result).toEqual(11);
486 +
487 + _updateCount(x => x - 10);
488 + result = shallowRenderer.render(element);
489 + expect(result).toEqual(5);
490 + });
491 });