Skip to content

Instantly share code, notes, and snippets.

@0x24a537r9
Created April 4, 2017 23:29
Show Gist options
  • Save 0x24a537r9/9bc7b40aab1ef5a6860db42ce9cf06b4 to your computer and use it in GitHub Desktop.
Save 0x24a537r9/9bc7b40aab1ef5a6860db42ce9cf06b4 to your computer and use it in GitHub Desktop.
/**
* @providesModule diffReact
* @flow
*/
import React from 'react';
import {diffLines} from 'diff';
import deepEqual from 'deep-equal';
import invariant from 'invariant';
import prettyFormat from 'pretty-format';
import reactTestPlugin from 'pretty-format/build/plugins/ReactTestComponent';
// NOTE: test renderer must be required after react-native.
import renderer from 'react-test-renderer';
// A React POJO (Plain Old Javascript Object), generated by calling
// ReactTestComponent.toJSON() (which actually creates a POJO, not JSON as the
// name would suggest).
type ReactPOJO =
| string
| {
$$typeof?: Symbol,
children: ?Array<ReactPOJO>,
props: {[key: string]: any},
type: string,
};
type CollapseResult = {
areEqual: boolean,
base: ReactPOJO,
test: ReactPOJO,
};
// In canonicalize(), we'll want to replace all function props with references
// to the same function so that deepEqual() doesn't identify them as different
// (since we really only want a diff of the visual props).
const SENTINEL_FUNCTION = () => {};
export default function diffReact(
base: React.Element<any>,
test: React.Element<any>,
): string {
// Recurse through the two rendered JSON DOMs in parallel, replacing equal
// props with a single `…="…"` prop and equal children with `…`.
const {
areEqual,
base: collapsedBase,
test: collapsedTest,
} = collapseReactPOJOs(
canonicalize(renderer.create(base).toJSON()),
canonicalize(renderer.create(test).toJSON()),
);
if (areEqual) {
return '(No visual differences)';
}
// Perform a standard line-based diff on the formatted, collapsed DOMs.
const diffs = diffLines(
formatReactPOJO(collapsedBase),
formatReactPOJO(collapsedTest),
);
// Format the diffs in the standard line-prefix (+, -, or ' ') diff format.
return diffs
.map(({added, removed, value}) =>
value
.replace(/[\r\n]+$/, '') // Strip any final newlines.
.split('\n')
.map(line => `${added ? '+' : removed ? '-' : ' '} ${line}`)
.join('\n'))
.join('\n');
}
function canonicalize(data: any): any {
if (typeof data === 'function') {
return SENTINEL_FUNCTION;
} else if (Array.isArray(data)) {
return data.map(canonicalize);
} else if (data instanceof Object) {
const canonicalizedData = {};
for (const key in data) {
canonicalizedData[key] = canonicalize(data[key]);
}
return canonicalizedData;
} else {
return data;
}
}
function collapseReactPOJOs(base: ReactPOJO, test: ReactPOJO): CollapseResult {
if (typeof base === 'string' && typeof test === 'string') {
return {base, test, areEqual: base === test};
} else if (
typeof base === 'string' ||
typeof test === 'string' ||
base.type !== test.type
) {
return {base, test, areEqual: false};
}
const allPropsAreEqual = deepEqual(base.props, test.props);
const newBaseProps = {...base.props};
const newTestProps = {...test.props};
for (const prop of Object.keys(base.props)) {
if (deepEqual(base.props[prop], test.props[prop])) {
// Delete the common props...
delete newBaseProps[prop];
delete newTestProps[prop];
// ... and replace them with a single '(equivalent props)="..."' prop.
newBaseProps['…'] = '…';
newTestProps['…'] = '…';
}
}
const allChildrenAreEqual = deepEqual(base.children, test.children);
let newBaseChildren = [];
let newTestChildren = [];
if (allChildrenAreEqual) {
if (base.children && base.children.length > 0) {
newBaseChildren.push('…');
newTestChildren.push('…');
}
} else {
const maxEqualChildren = base.children && test.children
? Math.min(base.children.length, test.children.length)
: 0;
let ii;
for (ii = 0; ii < maxEqualChildren; ++ii) {
invariant(
base.children && test.children,
'base and test should both have children if in this loop',
);
const {base: newBaseChild, test: newTestChild} = collapseReactPOJOs(
base.children[ii],
test.children[ii],
);
newBaseChildren[ii] = newBaseChild;
newTestChildren[ii] = newTestChild;
}
newBaseChildren = newBaseChildren.concat((base.children || []).slice(ii));
newTestChildren = newTestChildren.concat((test.children || []).slice(ii));
}
return {
areEqual: allPropsAreEqual && allChildrenAreEqual,
base: {
type: base.type,
props: newBaseProps,
children: newBaseChildren.length > 0 ? newBaseChildren : null,
},
test: {
type: test.type,
props: newTestProps,
children: newTestChildren.length > 0 ? newTestChildren : null,
},
};
}
function formatReactPOJO(reactPOJO: ReactPOJO) {
if (typeof reactPOJO === 'object') {
// HACK: Trick reactTestPlugin into thinking a ReactPOJO is a
// ReactTestComponent instance (which has the same type signature as a
// ReactPOJO).
reactPOJO.$$typeof = Symbol.for('react.test.json');
}
const prettyFormatted = prettyFormat(reactPOJO, {
escapeRegex: true,
plugins: [reactTestPlugin],
printFunctionName: false,
});
const prettyFormattedAndCollapsed = prettyFormatted
// Collapse tags with equal props and equal children like:
// <View …="…">…</View>
.replace(
/(\s*)<(\w+)\n\1 {2}…="…"\n\1>\n\1 {2}…\n\1<\/\2>/g,
'$1<$2 …="…">…</$2>',
)
// Collapse tags with equal props but differing or no children like:
// <View …="…">
// <View />
// </View>`.
// or:
// <View …="…"/>
.replace(/(\s*)<(\w+)\n\1 {2}…="…"\n\1(\/?)>/g, '$1<$2 …="…"$3>');
return prettyFormattedAndCollapsed;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment