Skip to content

Commit

Permalink
Support Hook state updates in shallow renderer
Browse files Browse the repository at this point in the history
  • Loading branch information
gaearon committed Mar 15, 2019
1 parent 8aee192 commit 4972adb
Show file tree
Hide file tree
Showing 2 changed files with 224 additions and 52 deletions.
110 changes: 58 additions & 52 deletions packages/react-test-renderer/src/ReactShallowRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type Update<A> = {
};

type UpdateQueue<A> = {
last: Update<A> | null,
first: Update<A> | null,
dispatch: any,
};

Expand Down Expand Up @@ -196,7 +196,6 @@ class ReactShallowRenderer {
this._isReRender = false;
this._didScheduleRenderPhaseUpdate = false;
this._renderPhaseUpdates = null;
this._currentlyRenderingComponent = null;
this._numberOfReRenders = 0;
}

Expand All @@ -211,15 +210,14 @@ class ReactShallowRenderer {
_dispatcher: DispatcherType;
_workInProgressHook: null | Hook;
_firstWorkInProgressHook: null | Hook;
_currentlyRenderingComponent: null | Object;
_renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null;
_isReRender: boolean;
_didScheduleRenderPhaseUpdate: boolean;
_numberOfReRenders: number;

_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)',
);
Expand All @@ -234,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) {
Expand All @@ -275,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];
}
};
Expand Down Expand Up @@ -375,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.
Expand All @@ -411,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);
}
}

Expand Down Expand Up @@ -443,10 +459,6 @@ class ReactShallowRenderer {
return this._workInProgressHook;
}

_prepareToUseHooks(componentIdentity: Object): void {
this._currentlyRenderingComponent = componentIdentity;
}

_finishHooks(element: ReactElement, context: null | Object) {
if (this._didScheduleRenderPhaseUpdate) {
// Updates were scheduled during the render phase. They are stored in
Expand All @@ -461,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;
Expand Down Expand Up @@ -572,10 +583,7 @@ class ReactShallowRenderer {
this._mountClassComponent(elementType, element, this._context);
} else {
let shouldRender = true;
if (
isMemo(element.type) &&
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)) {
Expand All @@ -585,8 +593,6 @@ class ReactShallowRenderer {
if (shouldRender) {
const prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = this._dispatcher;
const componentIdentity = {};
this._prepareToUseHooks(componentIdentity);
try {
// elementType could still be a ForwardRef if it was
// nested inside Memo.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
});
});

0 comments on commit 4972adb

Please sign in to comment.