Skip to content

Commit 7c0f5be

Browse files
committed
Support Hook state updates in shallow renderer
1 parent 2e4d021 commit 7c0f5be

File tree

2 files changed

+223
-48
lines changed

2 files changed

+223
-48
lines changed

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

Lines changed: 57 additions & 48 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

@@ -196,7 +196,6 @@ class ReactShallowRenderer {
196196
this._isReRender = false;
197197
this._didScheduleRenderPhaseUpdate = false;
198198
this._renderPhaseUpdates = null;
199-
this._currentlyRenderingComponent = null;
200199
this._numberOfReRenders = 0;
201200
}
202201

@@ -211,15 +210,14 @@ class ReactShallowRenderer {
211210
_dispatcher: DispatcherType;
212211
_workInProgressHook: null | Hook;
213212
_firstWorkInProgressHook: null | Hook;
214-
_currentlyRenderingComponent: null | Object;
215213
_renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null;
216214
_isReRender: boolean;
217215
_didScheduleRenderPhaseUpdate: boolean;
218216
_numberOfReRenders: number;
219217

220218
_validateCurrentlyRenderingComponent() {
221219
invariant(
222-
this._currentlyRenderingComponent !== null,
220+
this._rendering && !this._instance,
223221
'Hooks can only be called inside the body of a function component. ' +
224222
'(https://fb.me/react-invalid-hook-call)',
225223
);
@@ -234,33 +232,44 @@ class ReactShallowRenderer {
234232
this._validateCurrentlyRenderingComponent();
235233
this._createWorkInProgressHook();
236234
const workInProgressHook: Hook = (this._workInProgressHook: any);
235+
237236
if (this._isReRender) {
238-
// This is a re-render. Apply the new render phase updates to the previous
239-
// current hook.
237+
// This is a re-render.
240238
const queue: UpdateQueue<A> = (workInProgressHook.queue: any);
241239
const dispatch: Dispatch<A> = (queue.dispatch: any);
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-
// Process this render phase update. We don't have to check the
251-
// priority because it will always be the same as the current
252-
// render's.
253-
const action = update.action;
254-
newState = reducer(newState, action);
255-
update = update.next;
256-
} while (update !== null);
257-
258-
workInProgressHook.memoizedState = newState;
259-
260-
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+
}
261257
}
258+
return [workInProgressHook.memoizedState, dispatch];
262259
}
263-
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];
264273
} else {
265274
let initialState;
266275
if (reducer === basicStateReducer) {
@@ -275,16 +284,12 @@ class ReactShallowRenderer {
275284
}
276285
workInProgressHook.memoizedState = initialState;
277286
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
278-
last: null,
287+
first: null,
279288
dispatch: null,
280289
});
281290
const dispatch: Dispatch<
282291
A,
283-
> = (queue.dispatch = (this._dispatchAction.bind(
284-
this,
285-
(this._currentlyRenderingComponent: any),
286-
queue,
287-
): any));
292+
> = (queue.dispatch = (this._dispatchAction.bind(this, queue): any));
288293
return [workInProgressHook.memoizedState, dispatch];
289294
}
290295
};
@@ -375,18 +380,14 @@ class ReactShallowRenderer {
375380
};
376381
}
377382

378-
_dispatchAction<A>(
379-
componentIdentity: Object,
380-
queue: UpdateQueue<A>,
381-
action: A,
382-
) {
383+
_dispatchAction<A>(queue: UpdateQueue<A>, action: A) {
383384
invariant(
384385
this._numberOfReRenders < RE_RENDER_LIMIT,
385386
'Too many re-renders. React limits the number of renders to prevent ' +
386387
'an infinite loop.',
387388
);
388389

389-
if (componentIdentity === this._currentlyRenderingComponent) {
390+
if (this._rendering) {
390391
// This is a render phase update. Stash it in a lazily-created map of
391392
// queue -> linked list of updates. After this render pass, we'll restart
392393
// and apply the stashed updates on top of the work-in-progress hook.
@@ -411,9 +412,24 @@ class ReactShallowRenderer {
411412
lastRenderPhaseUpdate.next = update;
412413
}
413414
} else {
414-
// This means an update has happened after the function component has
415-
// returned. On the server this is a no-op. In React Fiber, the update
416-
// 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);
417433
}
418434
}
419435

@@ -443,10 +459,6 @@ class ReactShallowRenderer {
443459
return this._workInProgressHook;
444460
}
445461

446-
_prepareToUseHooks(componentIdentity: Object): void {
447-
this._currentlyRenderingComponent = componentIdentity;
448-
}
449-
450462
_finishHooks(element: ReactElement, context: null | Object) {
451463
if (this._didScheduleRenderPhaseUpdate) {
452464
// Updates were scheduled during the render phase. They are stored in
@@ -461,7 +473,6 @@ class ReactShallowRenderer {
461473
this._rendering = false;
462474
this.render(element, context);
463475
} else {
464-
this._currentlyRenderingComponent = null;
465476
this._workInProgressHook = null;
466477
this._renderPhaseUpdates = null;
467478
this._numberOfReRenders = 0;
@@ -560,8 +571,6 @@ class ReactShallowRenderer {
560571
} else {
561572
const prevDispatcher = ReactCurrentDispatcher.current;
562573
ReactCurrentDispatcher.current = this._dispatcher;
563-
const componentIdentity = {};
564-
this._prepareToUseHooks(componentIdentity);
565574
try {
566575
if (isForwardRef(element)) {
567576
this._rendered = element.type.render(element.props, element.ref);

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,61 @@ describe('ReactShallowRenderer with hooks', () => {
9090
);
9191
});
9292

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+
93148
it('should work with useReducer', () => {
94149
function reducer(state, action) {
95150
switch (action.type) {
@@ -322,4 +377,115 @@ describe('ReactShallowRenderer with hooks', () => {
322377

323378
expect(firstResult).toEqual(secondResult);
324379
});
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+
});
325491
});

0 commit comments

Comments
 (0)