packages/render/dom/__tests__/ReactMultiChildReconcile-test.js
const React = require('react');
const ReactDOM = require('react-dom');
const stripEmptyValues = function(obj) {
const ret = {};
for (const name in obj) {
if (!obj.hasOwnProperty(name)) {
continue;
}
if (obj[name] !== null && obj[name] !== undefined) {
ret[name] = obj[name];
}
}
return ret;
};
let idCounter = 123;
/**
* Contains internal static internal state in order to test that updates to
* existing children won't reinitialize components, when moving children -
* reusing existing DOM/memory resources.
*/
class StatusDisplay extends React.Component {
state = {internalState: idCounter++};
getStatus() {
return this.props.status;
}
getInternalState() {
return this.state.internalState;
}
componentDidMount() {
this.props.onFlush();
}
componentDidUpdate() {
this.props.onFlush();
}
render() {
return <div>{this.props.contentKey}</div>;
}
}
/**
* Displays friends statuses.
*/
class FriendsStatusDisplay extends React.Component {
/**
* Gets the order directly from each rendered child's `index` field.
* Refs are not maintained in the rendered order, and neither is
* `this._renderedChildren` (surprisingly).
*/
getOriginalKeys() {
const originalKeys = [];
for (const key in this.props.usernameToStatus) {
if (this.props.usernameToStatus[key]) {
originalKeys.push(key);
}
}
return originalKeys;
}
/**
* Retrieves the rendered children in a nice format for comparing to the input
* `this.props.usernameToStatus`.
*/
getStatusDisplays() {
const res = {};
const originalKeys = this.getOriginalKeys();
for (let i = 0; i < originalKeys.length; i++) {
const key = originalKeys[i];
res[key] = this.refs[key];
}
return res;
}
/**
* Verifies that by the time a child is flushed, the refs that appeared
* earlier have already been resolved.
* TODO: This assumption will likely break with incremental reconciler
* but our internal layer API depends on this assumption. We need to change
* it to be more declarative before making ref resolution indeterministic.
*/
verifyPreviousRefsResolved(flushedKey) {
const originalKeys = this.getOriginalKeys();
for (let i = 0; i < originalKeys.length; i++) {
const key = originalKeys[i];
if (key === flushedKey) {
// We are only interested in children up to the current key.
return;
}
expect(this.refs[key]).toBeTruthy();
}
}
render() {
const children = [];
for (const key in this.props.usernameToStatus) {
const status = this.props.usernameToStatus[key];
children.push(
!status ? null : (
<StatusDisplay
key={key}
ref={key}
contentKey={key}
onFlush={this.verifyPreviousRefsResolved.bind(this, key)}
status={status}
/>
),
);
}
const childrenToRender = this.props.prepareChildren(children);
return <div>{childrenToRender}</div>;
}
}
function getInternalStateByUserName(statusDisplays) {
return Object.keys(statusDisplays).reduce((acc, key) => {
acc[key] = statusDisplays[key].getInternalState();
return acc;
}, {});
}
/**
* Verifies that the rendered `StatusDisplay` instances match the `props` that
* were responsible for allocating them. Checks the content of the user's status
* message as well as the order of them.
*/
function verifyStatuses(statusDisplays, props) {
const nonEmptyStatusDisplays = stripEmptyValues(statusDisplays);
const nonEmptyStatusProps = stripEmptyValues(props.usernameToStatus);
let username;
expect(Object.keys(nonEmptyStatusDisplays).length).toEqual(
Object.keys(nonEmptyStatusProps).length,
);
for (username in nonEmptyStatusDisplays) {
if (!nonEmptyStatusDisplays.hasOwnProperty(username)) {
continue;
}
expect(nonEmptyStatusDisplays[username].getStatus()).toEqual(
nonEmptyStatusProps[username],
);
}
// now go the other way to make sure we got them all.
for (username in nonEmptyStatusProps) {
if (!nonEmptyStatusProps.hasOwnProperty(username)) {
continue;
}
expect(nonEmptyStatusDisplays[username].getStatus()).toEqual(
nonEmptyStatusProps[username],
);
}
expect(Object.keys(nonEmptyStatusDisplays)).toEqual(
Object.keys(nonEmptyStatusProps),
);
}
/**
* For all statusDisplays that existed in the previous iteration of the
* sequence, verify that the state has been preserved. `StatusDisplay` contains
* a unique number that allows us to track internal state across ordering
* movements.
*/
function verifyStatesPreserved(lastInternalStates, statusDisplays) {
let key;
for (key in statusDisplays) {
if (!statusDisplays.hasOwnProperty(key)) {
continue;
}
if (lastInternalStates[key]) {
expect(lastInternalStates[key]).toEqual(
statusDisplays[key].getInternalState(),
);
}
}
}
/**
* Verifies that the internal representation of a set of `renderedChildren`
* accurately reflects what is in the DOM.
*/
function verifyDomOrderingAccurate(outerContainer, statusDisplays) {
const containerNode = outerContainer.firstChild;
const statusDisplayNodes = containerNode.childNodes;
const orderedDomKeys = [];
for (let i = 0; i < statusDisplayNodes.length; i++) {
const contentKey = statusDisplayNodes[i].textContent;
orderedDomKeys.push(contentKey);
}
const orderedLogicalKeys = [];
let username;
for (username in statusDisplays) {
if (!statusDisplays.hasOwnProperty(username)) {
continue;
}
const statusDisplay = statusDisplays[username];
orderedLogicalKeys.push(statusDisplay.props.contentKey);
}
expect(orderedDomKeys).toEqual(orderedLogicalKeys);
}
function testPropsSequenceWithPreparedChildren(sequence, prepareChildren) {
const container = document.createElement('div');
const parentInstance = ReactDOM.render(
<FriendsStatusDisplay {...sequence[0]} prepareChildren={prepareChildren} />,
container,
);
let statusDisplays = parentInstance.getStatusDisplays();
let lastInternalStates = getInternalStateByUserName(statusDisplays);
verifyStatuses(statusDisplays, sequence[0]);
for (let i = 1; i < sequence.length; i++) {
ReactDOM.render(
<FriendsStatusDisplay
{...sequence[i]}
prepareChildren={prepareChildren}
/>,
container,
);
statusDisplays = parentInstance.getStatusDisplays();
verifyStatuses(statusDisplays, sequence[i]);
verifyStatesPreserved(lastInternalStates, statusDisplays);
verifyDomOrderingAccurate(container, statusDisplays);
lastInternalStates = getInternalStateByUserName(statusDisplays);
}
}
function prepareChildrenArray(childrenArray) {
return childrenArray;
}
function prepareChildrenIterable(childrenArray) {
return {
'@@iterator': function*() {
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const child of childrenArray) {
yield child;
}
},
};
}
function testPropsSequence(sequence) {
testPropsSequenceWithPreparedChildren(sequence, prepareChildrenArray);
testPropsSequenceWithPreparedChildren(sequence, prepareChildrenIterable);
}
describe('ReactMultiChildReconcile', () => {
beforeEach(() => {
jest.resetModules();
});
it('should reset internal state if removed then readded in an array', () => {
// Test basics.
const props = {
usernameToStatus: {
jcw: 'jcwStatus',
},
};
const container = document.createElement('div');
const parentInstance = ReactDOM.render(
<FriendsStatusDisplay
{...props}
prepareChildren={prepareChildrenArray}
/>,
container,
);
let statusDisplays = parentInstance.getStatusDisplays();
const startingInternalState = statusDisplays.jcw.getInternalState();
// Now remove the child.
ReactDOM.render(
<FriendsStatusDisplay prepareChildren={prepareChildrenArray} />,
container,
);
statusDisplays = parentInstance.getStatusDisplays();
expect(statusDisplays.jcw).toBeFalsy();
// Now reset the props that cause there to be a child
ReactDOM.render(
<FriendsStatusDisplay
{...props}
prepareChildren={prepareChildrenArray}
/>,
container,
);
statusDisplays = parentInstance.getStatusDisplays();
expect(statusDisplays.jcw).toBeTruthy();
expect(statusDisplays.jcw.getInternalState()).not.toBe(
startingInternalState,
);
});
it('should reset internal state if removed then readded in an iterable', () => {
// Test basics.
const props = {
usernameToStatus: {
jcw: 'jcwStatus',
},
};
const container = document.createElement('div');
const parentInstance = ReactDOM.render(
<FriendsStatusDisplay
{...props}
prepareChildren={prepareChildrenIterable}
/>,
container,
);
let statusDisplays = parentInstance.getStatusDisplays();
const startingInternalState = statusDisplays.jcw.getInternalState();
// Now remove the child.
ReactDOM.render(
<FriendsStatusDisplay prepareChildren={prepareChildrenIterable} />,
container,
);
statusDisplays = parentInstance.getStatusDisplays();
expect(statusDisplays.jcw).toBeFalsy();
// Now reset the props that cause there to be a child
ReactDOM.render(
<FriendsStatusDisplay
{...props}
prepareChildren={prepareChildrenIterable}
/>,
container,
);
statusDisplays = parentInstance.getStatusDisplays();
expect(statusDisplays.jcw).toBeTruthy();
expect(statusDisplays.jcw.getInternalState()).not.toBe(
startingInternalState,
);
});
it('should create unique identity', () => {
// Test basics.
const usernameToStatus = {
jcw: 'jcwStatus',
awalke: 'awalkeStatus',
bob: 'bobStatus',
};
testPropsSequence([{usernameToStatus: usernameToStatus}]);
});
it('should preserve order if children order has not changed', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should transition from zero to one children correctly', () => {
const PROPS_SEQUENCE = [
{usernameToStatus: {}},
{
usernameToStatus: {
first: 'firstStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should transition from one to zero children correctly', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
first: 'firstStatus',
},
},
{usernameToStatus: {}},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should transition from one child to null children', () => {
testPropsSequence([
{
usernameToStatus: {
first: 'firstStatus',
},
},
{},
]);
});
it('should transition from null children to one child', () => {
testPropsSequence([
{},
{
usernameToStatus: {
first: 'firstStatus',
},
},
]);
});
it('should transition from zero children to null children', () => {
testPropsSequence([
{
usernameToStatus: {},
},
{},
]);
});
it('should transition from null children to zero children', () => {
testPropsSequence([
{},
{
usernameToStatus: {},
},
]);
});
/**
* `FriendsStatusDisplay` renders nulls as empty children (it's a convention
* of `FriendsStatusDisplay`, nothing related to React or these test cases.
*/
it('should remove nulled out children at the beginning', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: null,
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should remove nulled out children at the end', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
jordanjcw: null,
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should reverse the order of two children', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
},
},
{
usernameToStatus: {
userTwo: 'userTwoStatus',
userOne: 'userOneStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should reverse the order of more than two children', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
},
},
{
usernameToStatus: {
userThree: 'userThreeStatus',
userTwo: 'userTwoStatus',
userOne: 'userOneStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should cycle order correctly', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
},
},
{
usernameToStatus: {
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
userOne: 'userOneStatus',
},
},
{
usernameToStatus: {
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
},
},
{
usernameToStatus: {
userFour: 'userFourStatus',
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
},
},
{
usernameToStatus: {
// Full circle!
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should cycle order correctly in the other direction', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
},
},
{
usernameToStatus: {
userFour: 'userFourStatus',
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
},
},
{
usernameToStatus: {
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
},
},
{
usernameToStatus: {
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
userOne: 'userOneStatus',
},
},
{
usernameToStatus: {
// Full circle!
userOne: 'userOneStatus',
userTwo: 'userTwoStatus',
userThree: 'userThreeStatus',
userFour: 'userFourStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should remove nulled out children and ignore new null children', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jordanjcw: 'jordanjcwstatus2',
jcw: null,
another: null,
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should remove nulled out children and reorder remaining', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
john: 'johnStatus', // john will go away
joe: 'joeStatus',
},
},
{
usernameToStatus: {
jordanjcw: 'jordanjcwStatus',
joe: 'joeStatus',
jcw: 'jcwStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should append children to the end', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
jordanjcwnew: 'jordanjcwnewStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should append multiple children to the end', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
jordanjcwnew: 'jordanjcwnewStatus',
jordanjcwnew2: 'jordanjcwnewStatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should prepend children to the beginning', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
newUsername: 'newUsernameStatus',
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should prepend multiple children to the beginning', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
newNewUsername: 'newNewUsernameStatus',
newUsername: 'newUsernameStatus',
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should not prepend an empty child to the beginning', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
emptyUsername: null,
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should not append an empty child to the end', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
emptyUsername: null,
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should not insert empty children in the middle', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
skipOverMe: null,
skipOverMeToo: null,
definitelySkipOverMe: null,
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should insert one new child in the middle', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
insertThis: 'insertThisStatus',
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should insert multiple new truthy children in the middle', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
insertThis: 'insertThisStatus',
insertThisToo: 'insertThisTooStatus',
definitelyInsertThisToo: 'definitelyInsertThisTooStatus',
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
it('should insert non-empty children in middle where nulls were', () => {
const PROPS_SEQUENCE = [
{
usernameToStatus: {
jcw: 'jcwStatus',
insertThis: null,
insertThisToo: null,
definitelyInsertThisToo: null,
jordanjcw: 'jordanjcwStatus',
},
},
{
usernameToStatus: {
jcw: 'jcwstatus2',
insertThis: 'insertThisStatus',
insertThisToo: 'insertThisTooStatus',
definitelyInsertThisToo: 'definitelyInsertThisTooStatus',
jordanjcw: 'jordanjcwstatus2',
},
},
];
testPropsSequence(PROPS_SEQUENCE);
});
});