Skip to content

Commit e6b4c33

Browse files
authored
Merge pull request #2301 from golopot/fix-cached-props-2
[New] `prop-types`: handle variables defined as props
2 parents 3ae3a9a + 9a63e19 commit e6b4c33

File tree

3 files changed

+220
-38
lines changed

3 files changed

+220
-38
lines changed

lib/util/usedPropTypes.js

+84-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,46 @@ const ast = require('./ast');
1515
const LIFE_CYCLE_METHODS = ['componentWillReceiveProps', 'shouldComponentUpdate', 'componentWillUpdate', 'componentDidUpdate'];
1616
const ASYNC_SAFE_LIFE_CYCLE_METHODS = ['getDerivedStateFromProps', 'getSnapshotBeforeUpdate', 'UNSAFE_componentWillReceiveProps', 'UNSAFE_componentWillUpdate'];
1717

18+
function createPropVariables() {
19+
/** @type {Map<string, string[]>} Maps the variable to its definition. `props.a.b` is stored as `['a', 'b']` */
20+
let propVariables = new Map();
21+
let hasBeenWritten = false;
22+
const stack = [{propVariables, hasBeenWritten}];
23+
return {
24+
pushScope() {
25+
// popVariables is not copied until first write.
26+
stack.push({propVariables, hasBeenWritten: false});
27+
},
28+
popScope() {
29+
stack.pop();
30+
propVariables = stack[stack.length - 1].propVariables;
31+
hasBeenWritten = stack[stack.length - 1].hasBeenWritten;
32+
},
33+
/**
34+
* Add a variable name to the current scope
35+
* @param {string} name
36+
* @param {string[]} allNames Example: `props.a.b` should be formatted as `['a', 'b']`
37+
*/
38+
set(name, allNames) {
39+
if (!hasBeenWritten) {
40+
// copy on write
41+
propVariables = new Map(propVariables);
42+
Object.assign(stack[stack.length - 1], {propVariables, hasBeenWritten: true});
43+
stack[stack.length - 1].hasBeenWritten = true;
44+
}
45+
return propVariables.set(name, allNames);
46+
},
47+
/**
48+
* Get the definition of a variable.
49+
* @param {string} name
50+
* @returns {string[]} Example: `props.a.b` is represented by `['a', 'b']`
51+
*/
52+
get(name) {
53+
return propVariables.get(name);
54+
}
55+
};
56+
}
57+
1858
/**
1959
* Checks if the string is one of `props`, `nextProps`, or `prevProps`
2060
* @param {string} name The AST node being checked.
@@ -230,6 +270,10 @@ function isPropTypesUsageByMemberExpression(node, context, utils, checkAsyncSafe
230270
module.exports = function usedPropTypesInstructions(context, components, utils) {
231271
const checkAsyncSafeLifeCycles = versionUtil.testReactVersion(context, '16.3.0');
232272

273+
const propVariables = createPropVariables();
274+
const pushScope = propVariables.pushScope;
275+
const popScope = propVariables.popScope;
276+
233277
/**
234278
* Mark a prop type as used
235279
* @param {ASTNode} node The AST node being marked.
@@ -261,6 +305,14 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
261305
node.parent.id.parent = node.parent; // patch for bug in eslint@4 in which ObjectPattern has no parent
262306
markPropTypesAsUsed(node.parent.id, allNames);
263307
}
308+
309+
// const a = props.a
310+
if (
311+
node.parent.type === 'VariableDeclarator' &&
312+
node.parent.id.type === 'Identifier'
313+
) {
314+
propVariables.set(node.parent.id.name, allNames);
315+
}
264316
// Do not mark computed props as used.
265317
type = name !== '__COMPUTED_PROP__' ? 'direct' : null;
266318
}
@@ -314,6 +366,7 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
314366
const propName = ast.getKeyValue(context, properties[k]);
315367

316368
if (propName) {
369+
propVariables.set(propName, parentNames.concat(propName));
317370
usedPropTypes.push({
318371
allNames: parentNames.concat([propName]),
319372
name: propName,
@@ -371,6 +424,7 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
371424
* FunctionDeclaration, or FunctionExpression
372425
*/
373426
function handleFunctionLikeExpressions(node) {
427+
pushScope();
374428
handleSetStateUpdater(node);
375429
markDestructuredFunctionArgumentsAsUsed(node);
376430
}
@@ -392,6 +446,11 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
392446

393447
return {
394448
VariableDeclarator(node) {
449+
// let props = this.props
450+
if (isThisDotProps(node.init) && isInClassComponent(utils) && node.id.type === 'Identifier') {
451+
propVariables.set(node.id.name, []);
452+
}
453+
395454
// Only handles destructuring
396455
if (node.id.type !== 'ObjectPattern') {
397456
return;
@@ -400,14 +459,19 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
400459
// let {props: {firstname}} = this
401460
const propsProperty = node.id.properties.find(property => (
402461
property.key &&
403-
(property.key.name === 'props' || property.key.value === 'props') &&
404-
property.value.type === 'ObjectPattern'
462+
(property.key.name === 'props' || property.key.value === 'props')
405463
));
406-
if (propsProperty && node.init.type === 'ThisExpression') {
464+
if (node.init.type === 'ThisExpression' && propsProperty && propsProperty.value.type === 'ObjectPattern') {
407465
markPropTypesAsUsed(propsProperty.value);
408466
return;
409467
}
410468

469+
// let {props} = this
470+
if (node.init.type === 'ThisExpression' && propsProperty && propsProperty.value.name === 'props') {
471+
propVariables.set('props', []);
472+
return;
473+
}
474+
411475
// let {firstname} = props
412476
if (
413477
isCommonVariableNameForProps(node.init.name) &&
@@ -420,6 +484,12 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
420484
// let {firstname} = this.props
421485
if (isThisDotProps(node.init) && isInClassComponent(utils)) {
422486
markPropTypesAsUsed(node.id);
487+
return;
488+
}
489+
490+
// let {firstname} = thing, where thing is defined by const thing = this.props.**.*
491+
if (propVariables.get(node.init.name)) {
492+
markPropTypesAsUsed(node, propVariables.get(node.init.name));
423493
}
424494
},
425495

@@ -429,6 +499,12 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
429499

430500
FunctionExpression: handleFunctionLikeExpressions,
431501

502+
'FunctionDeclaration:exit': popScope,
503+
504+
'ArrowFunctionExpression:exit': popScope,
505+
506+
'FunctionExpression:exit': popScope,
507+
432508
JSXSpreadAttribute(node) {
433509
const component = components.get(utils.getParentComponent());
434510
components.set(component ? component.node : node, {
@@ -439,6 +515,11 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
439515
MemberExpression(node) {
440516
if (isPropTypesUsageByMemberExpression(node, context, utils, checkAsyncSafeLifeCycles)) {
441517
markPropTypesAsUsed(node);
518+
return;
519+
}
520+
521+
if (propVariables.get(node.object.name)) {
522+
markPropTypesAsUsed(node, propVariables.get(node.object.name));
442523
}
443524
},
444525

tests/lib/rules/no-unused-prop-types.js

+53-21
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,59 @@ ruleTester.run('no-unused-prop-types', rule, {
479479
'};'
480480
].join('\n'),
481481
parser: parsers.BABEL_ESLINT
482+
}, {
483+
code: `
484+
function Foo({ a }) {
485+
return <>{ a.b }</>
486+
}
487+
Foo.propTypes = {
488+
a: PropTypes.shape({
489+
b: PropType.string,
490+
})
491+
}
492+
`,
493+
options: [{skipShapeProps: false}],
494+
parser: parsers.BABEL_ESLINT
495+
}, {
496+
// Destructured assignment with Shape propTypes with skipShapeProps off issue #816
497+
code: `
498+
class Thing extends React.Component {
499+
static propTypes = {
500+
i18n: PropTypes.shape({
501+
gettext: PropTypes.func,
502+
}),
503+
}
504+
505+
render() {
506+
const { i18n } = this.props;
507+
return (
508+
<p>{i18n.gettext('Some Text')}</p>
509+
);
510+
}
511+
}
512+
`,
513+
parser: parsers.BABEL_ESLINT,
514+
options: [{skipShapeProps: false}]
515+
},
516+
{
517+
code: `
518+
class Thing extends React.Component {
519+
static propTypes = {
520+
a: PropTypes.shape({
521+
b: PropTypes.string,
522+
}),
523+
}
524+
525+
render() {
526+
const { a } = this.props;
527+
return (
528+
<p>{ a.b }</p>
529+
);
530+
}
531+
}
532+
`,
533+
parser: parsers.BABEL_ESLINT,
534+
options: [{skipShapeProps: false}]
482535
}, {
483536
code: [
484537
'var Hello = createReactClass({',
@@ -4473,27 +4526,6 @@ ruleTester.run('no-unused-prop-types', rule, {
44734526
}, {
44744527
message: '\'prop2.*\' PropType is defined but prop is never used'
44754528
}]
4476-
}, {
4477-
// Destructured assignment with Shape propTypes with skipShapeProps off issue #816
4478-
code: [
4479-
'export default class NavigationButton extends React.Component {',
4480-
' static propTypes = {',
4481-
' route: PropTypes.shape({',
4482-
' getBarTintColor: PropTypes.func.isRequired,',
4483-
' }).isRequired,',
4484-
' };',
4485-
4486-
' renderTitle() {',
4487-
' const { route } = this.props;',
4488-
' return <Title tintColor={route.getBarTintColor()}>TITLE</Title>;',
4489-
' }',
4490-
'}'
4491-
].join('\n'),
4492-
parser: parsers.BABEL_ESLINT,
4493-
options: [{skipShapeProps: false}],
4494-
errors: [{
4495-
message: '\'route.getBarTintColor\' PropType is defined but prop is never used'
4496-
}]
44974529
}, {
44984530
code: [
44994531
// issue #1097

tests/lib/rules/prop-types.js

+83-14
Original file line numberDiff line numberDiff line change
@@ -870,17 +870,6 @@ ruleTester.run('prop-types', rule, {
870870
'};'
871871
].join('\n'),
872872
parser: parsers.BABEL_ESLINT
873-
}, {
874-
// Reassigned props are ignored
875-
code: [
876-
'export class Hello extends Component {',
877-
' render() {',
878-
' const props = this.props;',
879-
' return <div>Hello {props.name.firstname} {props[\'name\'].lastname}</div>',
880-
' }',
881-
'}'
882-
].join('\n'),
883-
parser: parsers.BABEL_ESLINT
884873
}, {
885874
code: [
886875
'export default function FooBar(props) {',
@@ -2453,6 +2442,82 @@ ruleTester.run('prop-types', rule, {
24532442
{message: "'foo' is missing in props validation"},
24542443
{message: "'foo.bar' is missing in props validation"}
24552444
]
2445+
},
2446+
{
2447+
code: `
2448+
function Foo({ a }) {
2449+
return <p>{ a.nope }</p>
2450+
}
2451+
2452+
Foo.propTypes = {
2453+
a: PropTypes.shape({
2454+
_: PropType.string,
2455+
})
2456+
}
2457+
`,
2458+
errors: [
2459+
{message: "'a.nope' is missing in props validation"}
2460+
]
2461+
},
2462+
{
2463+
code: `
2464+
function Foo(props) {
2465+
const { a } = props
2466+
return <p>{ a.nope }</p>
2467+
}
2468+
2469+
Foo.propTypes = {
2470+
a: PropTypes.shape({
2471+
_: PropType.string,
2472+
})
2473+
}
2474+
`,
2475+
errors: [
2476+
{message: "'a.nope' is missing in props validation"}
2477+
]
2478+
},
2479+
{
2480+
code: `
2481+
function Foo(props) {
2482+
const a = props.a
2483+
return <p>{ a.nope }</p>
2484+
}
2485+
2486+
Foo.propTypes = {
2487+
a: PropTypes.shape({
2488+
_: PropType.string,
2489+
})
2490+
}
2491+
`,
2492+
errors: [
2493+
{message: "'a.nope' is missing in props validation"}
2494+
]
2495+
},
2496+
{
2497+
code: `
2498+
class Foo extends Component {
2499+
render() {
2500+
const props = this.props
2501+
return <div>{props.cat}</div>
2502+
}
2503+
}
2504+
`,
2505+
errors: [
2506+
{message: "'cat' is missing in props validation"}
2507+
]
2508+
},
2509+
{
2510+
code: `
2511+
class Foo extends Component {
2512+
render() {
2513+
const {props} = this
2514+
return <div>{props.cat}</div>
2515+
}
2516+
}
2517+
`,
2518+
errors: [
2519+
{message: "'cat' is missing in props validation"}
2520+
]
24562521
}, {
24572522
code: [
24582523
'class Hello extends React.Component {',
@@ -3364,6 +3429,8 @@ ruleTester.run('prop-types', rule, {
33643429
].join('\n'),
33653430
errors: [{
33663431
message: '\'names\' is missing in props validation'
3432+
}, {
3433+
message: '\'names.map\' is missing in props validation'
33673434
}]
33683435
}, {
33693436
code: [
@@ -4521,9 +4588,11 @@ ruleTester.run('prop-types', rule, {
45214588
}
45224589
`,
45234590
parser: parsers.BABEL_ESLINT,
4524-
errors: [{
4525-
message: '\'a\' is missing in props validation'
4526-
}]
4591+
errors: [
4592+
{message: '\'a\' is missing in props validation'},
4593+
{message: '\'a.b\' is missing in props validation'},
4594+
{message: '\'a.b.c\' is missing in props validation'}
4595+
]
45274596
},
45284597
{
45294598
code: `

0 commit comments

Comments
 (0)