[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
This commit is contained in:
Dan Abramov 2019-03-15 22:30:32 +00:00 committed by GitHub
parent 035e4cffbd
commit 8d60bd4dc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 274 additions and 69 deletions

View File

@ -31,7 +31,7 @@ type Update<A> = {
};
type UpdateQueue<A> = {
last: Update<A> | null,
first: Update<A> | null,
dispatch: any,
};
@ -178,6 +178,10 @@ class ReactShallowRenderer {
};
constructor() {
this._reset();
}
_reset() {
this._context = null;
this._element = null;
this._instance = null;
@ -192,9 +196,7 @@ class ReactShallowRenderer {
this._isReRender = false;
this._didScheduleRenderPhaseUpdate = false;
this._renderPhaseUpdates = null;
this._currentlyRenderingComponent = null;
this._numberOfReRenders = 0;
this._previousComponentIdentity = null;
}
_context: null | Object;
@ -208,8 +210,6 @@ class ReactShallowRenderer {
_dispatcher: DispatcherType;
_workInProgressHook: null | Hook;
_firstWorkInProgressHook: null | Hook;
_currentlyRenderingComponent: null | Object;
_previousComponentIdentity: null | Object;
_renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null;
_isReRender: boolean;
_didScheduleRenderPhaseUpdate: boolean;
@ -217,7 +217,7 @@ class ReactShallowRenderer {
_validateCurrentlyRenderingComponent() {
invariant(
this._currentlyRenderingComponent !== null,
this._rendering && !this._instance,
'Hooks can only be called inside the body of a function component. ' +
'(https://fb.me/react-invalid-hook-call)',
);
@ -232,33 +232,44 @@ class ReactShallowRenderer {
this._validateCurrentlyRenderingComponent();
this._createWorkInProgressHook();
const workInProgressHook: Hook = (this._workInProgressHook: any);
if (this._isReRender) {
// This is a re-render. Apply the new render phase updates to the previous
// current hook.
// This is a re-render.
const queue: UpdateQueue<A> = (workInProgressHook.queue: any);
const dispatch: Dispatch<A> = (queue.dispatch: any);
if (this._renderPhaseUpdates !== null) {
// Render phase updates are stored in a map of queue -> linked list
const firstRenderPhaseUpdate = this._renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate !== undefined) {
(this._renderPhaseUpdates: any).delete(queue);
let newState = workInProgressHook.memoizedState;
let update = firstRenderPhaseUpdate;
do {
// Process this render phase update. We don't have to check the
// priority because it will always be the same as the current
// render's.
const action = update.action;
newState = reducer(newState, action);
update = update.next;
} while (update !== null);
workInProgressHook.memoizedState = newState;
return [newState, dispatch];
if (this._numberOfReRenders > 0) {
// Apply the new render phase updates to the previous current hook.
if (this._renderPhaseUpdates !== null) {
// Render phase updates are stored in a map of queue -> linked list
const firstRenderPhaseUpdate = this._renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate !== undefined) {
(this._renderPhaseUpdates: any).delete(queue);
let newState = workInProgressHook.memoizedState;
let update = firstRenderPhaseUpdate;
do {
const action = update.action;
newState = reducer(newState, action);
update = update.next;
} while (update !== null);
workInProgressHook.memoizedState = newState;
return [newState, dispatch];
}
}
return [workInProgressHook.memoizedState, dispatch];
}
return [workInProgressHook.memoizedState, dispatch];
// Process updates outside of render
let newState = workInProgressHook.memoizedState;
let update = queue.first;
if (update !== null) {
do {
const action = update.action;
newState = reducer(newState, action);
update = update.next;
} while (update !== null);
queue.first = null;
workInProgressHook.memoizedState = newState;
}
return [newState, dispatch];
} else {
let initialState;
if (reducer === basicStateReducer) {
@ -273,16 +284,12 @@ class ReactShallowRenderer {
}
workInProgressHook.memoizedState = initialState;
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
last: null,
first: null,
dispatch: null,
});
const dispatch: Dispatch<
A,
> = (queue.dispatch = (this._dispatchAction.bind(
this,
(this._currentlyRenderingComponent: any),
queue,
): any));
> = (queue.dispatch = (this._dispatchAction.bind(this, queue): any));
return [workInProgressHook.memoizedState, dispatch];
}
};
@ -373,18 +380,14 @@ class ReactShallowRenderer {
};
}
_dispatchAction<A>(
componentIdentity: Object,
queue: UpdateQueue<A>,
action: A,
) {
_dispatchAction<A>(queue: UpdateQueue<A>, action: A) {
invariant(
this._numberOfReRenders < RE_RENDER_LIMIT,
'Too many re-renders. React limits the number of renders to prevent ' +
'an infinite loop.',
);
if (componentIdentity === this._currentlyRenderingComponent) {
if (this._rendering) {
// This is a render phase update. Stash it in a lazily-created map of
// queue -> linked list of updates. After this render pass, we'll restart
// and apply the stashed updates on top of the work-in-progress hook.
@ -409,9 +412,24 @@ class ReactShallowRenderer {
lastRenderPhaseUpdate.next = update;
}
} else {
// This means an update has happened after the function component has
// returned. On the server this is a no-op. In React Fiber, the update
// would be scheduled for a future render.
const update: Update<A> = {
action,
next: null,
};
// Append the update to the end of the list.
let last = queue.first;
if (last === null) {
queue.first = update;
} else {
while (last.next !== null) {
last = last.next;
}
last.next = update;
}
// Re-render now.
this.render(this._element, this._context);
}
}
@ -441,17 +459,6 @@ class ReactShallowRenderer {
return this._workInProgressHook;
}
_prepareToUseHooks(componentIdentity: Object): void {
if (
this._previousComponentIdentity !== null &&
this._previousComponentIdentity !== componentIdentity
) {
this._firstWorkInProgressHook = null;
}
this._currentlyRenderingComponent = componentIdentity;
this._previousComponentIdentity = componentIdentity;
}
_finishHooks(element: ReactElement, context: null | Object) {
if (this._didScheduleRenderPhaseUpdate) {
// Updates were scheduled during the render phase. They are stored in
@ -466,7 +473,6 @@ class ReactShallowRenderer {
this._rendering = false;
this.render(element, context);
} else {
this._currentlyRenderingComponent = null;
this._workInProgressHook = null;
this._renderPhaseUpdates = null;
this._numberOfReRenders = 0;
@ -514,6 +520,9 @@ class ReactShallowRenderer {
if (this._rendering) {
return;
}
if (this._element != null && this._element.type !== element.type) {
this._reset();
}
const elementType = isMemo(element.type) ? element.type.type : element.type;
const previousElement = this._element;
@ -574,11 +583,7 @@ class ReactShallowRenderer {
this._mountClassComponent(elementType, element, this._context);
} else {
let shouldRender = true;
if (
isMemo(element.type) &&
elementType === this._previousComponentIdentity &&
previousElement !== null
) {
if (isMemo(element.type) && previousElement !== null) {
// This is a Memo component that is being re-rendered.
const compare = element.type.compare || shallowEqual;
if (compare(previousElement.props, element.props)) {
@ -588,7 +593,6 @@ class ReactShallowRenderer {
if (shouldRender) {
const prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = this._dispatcher;
this._prepareToUseHooks(elementType);
try {
// elementType could still be a ForwardRef if it was
// nested inside Memo.
@ -626,14 +630,7 @@ class ReactShallowRenderer {
this._instance.componentWillUnmount();
}
}
this._firstWorkInProgressHook = null;
this._previousComponentIdentity = null;
this._context = null;
this._element = null;
this._newState = null;
this._rendered = null;
this._instance = null;
this._reset();
}
_mountClassComponent(

View File

@ -1565,4 +1565,46 @@ describe('ReactShallowRenderer', () => {
'forwardRef requires a render function but was given object.',
);
});
it('should let you change type', () => {
function Foo({prop}) {
return <div>Foo {prop}</div>;
}
function Bar({prop}) {
return <div>Bar {prop}</div>;
}
const shallowRenderer = createRenderer();
shallowRenderer.render(<Foo prop="foo1" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo1'}</div>);
shallowRenderer.render(<Foo prop="foo2" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo2'}</div>);
shallowRenderer.render(<Bar prop="bar1" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar1'}</div>);
shallowRenderer.render(<Bar prop="bar2" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar2'}</div>);
});
it('should let you change class type', () => {
class Foo extends React.Component {
render() {
return <div>Foo {this.props.prop}</div>;
}
}
class Bar extends React.Component {
render() {
return <div>Bar {this.props.prop}</div>;
}
}
const shallowRenderer = createRenderer();
shallowRenderer.render(<Foo prop="foo1" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo1'}</div>);
shallowRenderer.render(<Foo prop="foo2" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Foo {'foo2'}</div>);
shallowRenderer.render(<Bar prop="bar1" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar1'}</div>);
shallowRenderer.render(<Bar prop="bar2" />);
expect(shallowRenderer.getRenderOutput()).toEqual(<div>Bar {'bar2'}</div>);
});
});

View File

@ -90,6 +90,61 @@ describe('ReactShallowRenderer with hooks', () => {
);
});
it('should work with updating a derived value from useState', () => {
let _updateName;
function SomeComponent({defaultName}) {
const [name, updateName] = React.useState(defaultName);
const [prevName, updatePrevName] = React.useState(defaultName);
const [letter, updateLetter] = React.useState(name[0]);
_updateName = updateName;
if (name !== prevName) {
updatePrevName(name);
updateLetter(name[0]);
}
return (
<div>
<p>
Your name is: <span>{name + ' (' + letter + ')'}</span>
</p>
</div>
);
}
const shallowRenderer = createRenderer();
let result = shallowRenderer.render(
<SomeComponent defaultName={'Sophie'} />,
);
expect(result).toEqual(
<div>
<p>
Your name is: <span>Sophie (S)</span>
</p>
</div>,
);
result = shallowRenderer.render(<SomeComponent defaultName={'Dan'} />);
expect(result).toEqual(
<div>
<p>
Your name is: <span>Sophie (S)</span>
</p>
</div>,
);
_updateName('Dan');
expect(shallowRenderer.getRenderOutput()).toEqual(
<div>
<p>
Your name is: <span>Dan (D)</span>
</p>
</div>,
);
});
it('should work with useReducer', () => {
function reducer(state, action) {
switch (action.type) {
@ -322,4 +377,115 @@ describe('ReactShallowRenderer with hooks', () => {
expect(firstResult).toEqual(secondResult);
});
it('should update a value from useState outside the render', () => {
let _dispatch;
function SomeComponent({defaultName}) {
const [count, dispatch] = React.useReducer(
(s, a) => (a === 'inc' ? s + 1 : s),
0,
);
const [name, updateName] = React.useState(defaultName);
_dispatch = () => dispatch('inc');
return (
<div onClick={() => updateName('Dan')}>
<p>
Your name is: <span>{name}</span> ({count})
</p>
</div>
);
}
const shallowRenderer = createRenderer();
const element = <SomeComponent defaultName={'Dominic'} />;
const result = shallowRenderer.render(element);
expect(result.props.children).toEqual(
<p>
Your name is: <span>Dominic</span> ({0})
</p>,
);
result.props.onClick();
let updated = shallowRenderer.render(element);
expect(updated.props.children).toEqual(
<p>
Your name is: <span>Dan</span> ({0})
</p>,
);
_dispatch('foo');
updated = shallowRenderer.render(element);
expect(updated.props.children).toEqual(
<p>
Your name is: <span>Dan</span> ({1})
</p>,
);
_dispatch('inc');
updated = shallowRenderer.render(element);
expect(updated.props.children).toEqual(
<p>
Your name is: <span>Dan</span> ({2})
</p>,
);
});
it('should ignore a foreign update outside the render', () => {
let _updateCountForFirstRender;
function SomeComponent() {
const [count, updateCount] = React.useState(0);
if (!_updateCountForFirstRender) {
_updateCountForFirstRender = updateCount;
}
return count;
}
const shallowRenderer = createRenderer();
const element = <SomeComponent />;
let result = shallowRenderer.render(element);
expect(result).toEqual(0);
_updateCountForFirstRender(1);
result = shallowRenderer.render(element);
expect(result).toEqual(1);
shallowRenderer.unmount();
result = shallowRenderer.render(element);
expect(result).toEqual(0);
_updateCountForFirstRender(1); // Should be ignored.
result = shallowRenderer.render(element);
expect(result).toEqual(0);
});
it('should not forget render phase updates', () => {
let _updateCount;
function SomeComponent() {
const [count, updateCount] = React.useState(0);
_updateCount = updateCount;
if (count < 5) {
updateCount(x => x + 1);
}
return count;
}
const shallowRenderer = createRenderer();
const element = <SomeComponent />;
let result = shallowRenderer.render(element);
expect(result).toEqual(5);
_updateCount(10);
result = shallowRenderer.render(element);
expect(result).toEqual(10);
_updateCount(x => x + 1);
result = shallowRenderer.render(element);
expect(result).toEqual(11);
_updateCount(x => x - 10);
result = shallowRenderer.render(element);
expect(result).toEqual(5);
});
});