Skip to content

Commit

Permalink
Merge pull request #93 from EECOLOR/jsx-spread-object-expression
Browse files Browse the repository at this point in the history
support retrieving props from a spread with object expression
  • Loading branch information
ljharb committed Oct 22, 2019
2 parents 42a85f8 + fefc9bb commit efcd031
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 11 deletions.
1 change: 1 addition & 0 deletions .babelrc
Expand Up @@ -2,5 +2,6 @@
"presets": ["env"],
"plugins": [
["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }],
"transform-object-rest-spread",
],
}
5 changes: 4 additions & 1 deletion .eslintrc
@@ -1,3 +1,6 @@
{
extends: "airbnb-base"
extends: "airbnb-base",
rules: {
no-use-before-define: ["error", { functions: false }]
}
}
2 changes: 1 addition & 1 deletion __tests__/helper.js
Expand Up @@ -36,7 +36,7 @@ function parse(code) {
}
if (parserName === 'babel') {
try {
return babelParser.parse(code, { plugins });
return babelParser.parse(code, { plugins, sourceFilename: 'test.js' });
} catch (_) {
// eslint-disable-next-line no-console
console.warn(`Failed to parse with ${fallbackToBabylon ? 'babylon' : 'Babel'} parser.`);
Expand Down
136 changes: 136 additions & 0 deletions __tests__/src/getProp-parser-test.js
@@ -0,0 +1,136 @@
/* eslint-env mocha */
import assert from 'assert';
import entries from 'object.entries';
import fromEntries from 'object.fromentries';
import { getOpeningElement, setParserName, fallbackToBabylon } from '../helper';
import getProp from '../../src/getProp';

const literal = {
source: '<div {...{ id: "foo" }} />',
target: '<div id="foo" />',
offset: { keyOffset: -6, valueOffset: -7 },
};

const expression1 = {
source: '<div {...{ id }} />',
target: '<div id={id} />',
offset: { keyOffset: -6, valueOffset: -2 },
};

const expression2 = {
source: '<div {...{ id: `foo${bar}baz` }} />', // eslint-disable-line no-template-curly-in-string
target: '<div id={`foo${bar}baz`} />', // eslint-disable-line no-template-curly-in-string
offset: { keyOffset: -6, valueOffset: -6 },
};

describe('getProp', () => {
it('should create the correct AST for literal with flow parser', () => {
actualTest('flow', literal);
});
it('should create the correct AST for literal with babel parser', () => {
actualTest('babel', literal);
});
it('should create the correct AST for expression with flow parser (1)', () => {
actualTest('flow', expression1);
});
it('should create the correct AST for expression with babel parser (1)', () => {
actualTest('babel', expression1);
});
it('should create the correct AST for expression with flow parser (2)', () => {
actualTest('flow', expression2);
});
it('should create the correct AST for expression with babel parser (2)', () => {
actualTest('babel', expression2);
});
});

function actualTest(parserName, test) {
setParserName(parserName);
const { source, target, offset } = test;
const sourceProps = stripConstructors(getOpeningElement(source).attributes);
const targetProps = stripConstructors(getOpeningElement(target).attributes);
const prop = 'id';
const sourceResult = getProp(sourceProps, prop);
const targetResult = getProp(targetProps, prop);

if (fallbackToBabylon && parserName === 'babel' && test === literal) {
// Babylon (node < 6) adds an `extra: null` prop to a literal if it is parsed from a
// JSXAttribute, other literals don't get this.
sourceResult.value.extra = null;
}

assert.deepStrictEqual(
adjustLocations(sourceResult, offset),
targetResult,
);
}

function stripConstructors(value) {
return JSON.parse(JSON.stringify(value));
}

function adjustLocations(node, { keyOffset, valueOffset }) {
const hasExpression = !!node.value.expression;
return {
...adjustNodeLocations(node, {
startOffset: keyOffset,
endOffset: valueOffset + (hasExpression ? 1 : 0),
}),
name: adjustNodeLocations(node.name, { startOffset: keyOffset, endOffset: keyOffset }),
value: {
...adjustNodeLocations(node.value, {
startOffset: valueOffset - (hasExpression ? 1 : 0),
endOffset: valueOffset + (hasExpression ? 1 : 0),
}),
...(hasExpression
? {
expression: adjustLocationsRecursively(
node.value.expression,
{ startOffset: valueOffset, endOffset: valueOffset },
),
}
: {}
),
},
};
}

function adjustNodeLocations(node, { startOffset, endOffset }) {
if (!node.loc) return node;
const [start, end] = node.range || [];
return {
...node,
...(node.start !== undefined ? { start: node.start + startOffset } : {}),
...(node.end !== undefined ? { end: node.end + endOffset } : {}),
loc: {
...node.loc,
start: {
...node.loc.start,
column: node.loc.start.column + startOffset,
},
end: {
...node.loc.end,
column: node.loc.end.column + endOffset,
},
},
...(node.range !== undefined ? { range: [start + startOffset, end + endOffset] } : {}),
};
}

function adjustLocationsRecursively(node, { startOffset, endOffset }) {
if (Array.isArray(node)) {
return node.map(x => adjustLocationsRecursively(x, { startOffset, endOffset }));
}
if (node && typeof node === 'object') {
return adjustNodeLocations(
mapValues(node, x => adjustLocationsRecursively(x, { startOffset, endOffset })),
{ startOffset, endOffset },
);
}

return node;
}

function mapValues(o, f) {
return fromEntries(entries(o).map(([k, v]) => [k, f(v)]));
}
66 changes: 66 additions & 0 deletions __tests__/src/getProp-test.js
Expand Up @@ -45,6 +45,72 @@ describe('getProp', () => {
assert.equal(expected, actual);
});

it('should return the correct attribute if the attribute exists in spread', () => {
const code = '<div {...{ id: "foo" }} />';
const node = getOpeningElement(code);
const { attributes: props } = node;
const prop = 'ID';

const expected = 'id';
const actual = getProp(props, prop).name.name;

assert.equal(expected, actual);
});

it('should return the correct attribute if the attribute exists in spread as an expression', () => {
const code = '<div {...{ id }} />';
const node = getOpeningElement(code);
const { attributes: props } = node;
const prop = 'id';

const expected = 'id';
const actual = getProp(props, prop);
const actualName = actual.name.name;
const actualValue = actual.value.expression.name;

assert.equal(expected, actualName);
assert.equal(expected, actualValue);
});

it('should return the correct attribute if the attribute exists in spread (case sensitive)', () => {
const code = '<div {...{ id: "foo" }} />';
const node = getOpeningElement(code);
const { attributes: props } = node;
const prop = 'id';
const options = { ignoreCase: false };

const expected = 'id';
const actual = getProp(props, prop, options).name.name;

assert.equal(expected, actual);
});

it('should return undefined if the attribute does not exist in spread (case sensitive)', () => {
const code = '<div {...{ id: "foo" }} />';
const node = getOpeningElement(code);
const { attributes: props } = node;
const prop = 'ID';
const options = { ignoreCase: false };

const expected = undefined;
const actual = getProp(props, prop, options);

assert.equal(expected, actual);
});

it('should return undefined for key in spread', () => {
// https://github.com/reactjs/rfcs/pull/107
const code = '<div {...{ key }} />';
const node = getOpeningElement(code);
const { attributes: props } = node;
const prop = 'key';

const expected = undefined;
const actual = getProp(props, prop);

assert.equal(expected, actual);
});

it('should return undefined if the attribute may exist in spread', () => {
const code = '<div {...props} />';
const node = getOpeningElement(code);
Expand Down
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -20,6 +20,7 @@
"babel-core": "^6.26.3",
"babel-eslint": "^10.0.2",
"babel-jest": "^20.0.3",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-replace-object-assign": "^1.0.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
Expand All @@ -32,6 +33,8 @@
"in-publish": "^2.0.0",
"jest": "^20.0.4",
"jest-cli": "^20.0.4",
"object.entries": "^1.1.0",
"object.fromentries": "^2.0.1",
"rimraf": "^2.6.3"
},
"engines": {
Expand Down
66 changes: 57 additions & 9 deletions src/getProp.js
Expand Up @@ -10,18 +10,66 @@ const DEFAULT_OPTIONS = {
*
*/
export default function getProp(props = [], prop = '', options = DEFAULT_OPTIONS) {
const propToFind = options.ignoreCase ? prop.toUpperCase() : prop;
function getName(name) { return options.ignoreCase ? name.toUpperCase() : name; }
const propToFind = getName(prop);
function isPropToFind(property) {
return property.key.type === 'Identifier' && propToFind === getName(property.key.name);
}

return props.find((attribute) => {
// If the props contain a spread prop, then skip.
const foundAttribute = props.find((attribute) => {
// If the props contain a spread prop, try to find the property in the object expression.
if (attribute.type === 'JSXSpreadAttribute') {
return false;
return attribute.argument.type === 'ObjectExpression'
&& propToFind !== getName('key') // https://github.com/reactjs/rfcs/pull/107
&& attribute.argument.properties.some(isPropToFind);
}

const currentProp = options.ignoreCase
? propName(attribute).toUpperCase()
: propName(attribute);

return propToFind === currentProp;
return propToFind === getName(propName(attribute));
});

if (foundAttribute && foundAttribute.type === 'JSXSpreadAttribute') {
return propertyToJSXAttribute(foundAttribute.argument.properties.find(isPropToFind));
}

return foundAttribute;
}

function propertyToJSXAttribute(node) {
const { key, value } = node;
return {
type: 'JSXAttribute',
name: { type: 'JSXIdentifier', name: key.name, ...getBaseProps(key) },
value: value.type === 'Literal'
? value
: { type: 'JSXExpressionContainer', expression: value, ...getBaseProps(value) },
...getBaseProps(node),
};
}

function getBaseProps({
start,
end,
loc,
range,
}) {
return {
loc: getBaseLocation(loc),
...(start !== undefined ? { start } : {}),
...(end !== undefined ? { end } : {}),
...(range !== undefined ? { range } : {}),
};
}

function getBaseLocation({
start,
end,
source,
filename,
}) {
return {
start,
end,
...(source !== undefined ? { source } : {}),
...(filename !== undefined ? { filename } : {}),
};
}

0 comments on commit efcd031

Please sign in to comment.