Skip to content

Commit 6b6309b

Browse files
committed
chore(Image): use React.forwardRef() (#4234)
1 parent 4f7ffc8 commit 6b6309b

File tree

13 files changed

+148
-52
lines changed

13 files changed

+148
-52
lines changed

docs/src/utils/docTypes/componentInfoShape.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const componentInfoShape = PropTypes.shape({
1717
name: PropTypes.string,
1818
}),
1919
),
20-
constructorName: PropTypes.string.isRequired,
2120
type: PropTypes.string.isRequired,
2221
isParent: PropTypes.bool.isRequired,
2322
isChild: PropTypes.bool.isRequired,

gulp/plugins/util/getComponentInfo.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ const getComponentInfo = (filepath) => {
4343
// remove keys we don't use
4444
delete info.methods
4545

46-
// add exported Component info
47-
const Component = require(absPath).default
48-
info.constructorName = _.get(Component, 'prototype.constructor.name', null)
46+
if (!info.displayName) {
47+
throw new Error(
48+
`Please check that static property "displayName" is defined on a component in "${filepath}".`,
49+
)
50+
}
4951

5052
// add component type
5153
info.type = componentType

src/elements/Image/Image.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import ImageGroup from './ImageGroup'
2525
* An image is a graphic representation of something.
2626
* @see Icon
2727
*/
28-
function Image(props) {
28+
const Image = React.forwardRef(function ImageInner(props, ref) {
2929
const {
3030
avatar,
3131
bordered,
@@ -68,8 +68,10 @@ function Image(props) {
6868
'image',
6969
className,
7070
)
71+
7172
const rest = getUnhandledProps(Image, props)
7273
const [imgTagProps, rootProps] = partitionHTMLProps(rest, { htmlProps: htmlImageProps })
74+
7375
const ElementType = getElementType(Image, props, () => {
7476
if (
7577
!_.isNil(dimmer) ||
@@ -83,33 +85,36 @@ function Image(props) {
8385

8486
if (!childrenUtils.isNil(children)) {
8587
return (
86-
<ElementType {...rest} className={classes}>
88+
<ElementType {...rest} className={classes} ref={ref}>
8789
{children}
8890
</ElementType>
8991
)
9092
}
9193
if (!childrenUtils.isNil(content)) {
9294
return (
93-
<ElementType {...rest} className={classes}>
95+
<ElementType {...rest} className={classes} ref={ref}>
9496
{content}
9597
</ElementType>
9698
)
9799
}
98100

99101
if (ElementType === 'img') {
100-
return <ElementType {...rootProps} {...imgTagProps} className={classes} />
102+
return <ElementType {...rootProps} {...imgTagProps} className={classes} ref={ref} />
101103
}
104+
102105
return (
103106
<ElementType {...rootProps} className={classes} href={href}>
104107
{Dimmer.create(dimmer, { autoGenerateKey: false })}
105108
{Label.create(label, { autoGenerateKey: false })}
106-
<img {...imgTagProps} />
109+
110+
<img {...imgTagProps} ref={ref} />
107111
</ElementType>
108112
)
109-
}
113+
})
110114

111115
Image.Group = ImageGroup
112116

117+
Image.displayName = 'Image'
113118
Image.propTypes = {
114119
/** An element type to render as (string or function). */
115120
as: PropTypes.elementType,

src/lib/factories.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import cx from 'clsx'
22
import _ from 'lodash'
33
import * as React from 'react'
4+
import * as ReactIs from 'react-is'
45

56
const DEPRECATED_CALLS = {}
67

@@ -21,8 +22,8 @@ const DEPRECATED_CALLS = {}
2122
* @returns {object|null}
2223
*/
2324
export function createShorthand(Component, mapValueToProps, val, options = {}) {
24-
if (typeof Component !== 'function' && typeof Component !== 'string') {
25-
throw new Error('createShorthand() Component must be a string or function.')
25+
if (!ReactIs.isValidElementType(Component)) {
26+
throw new Error('createShorthand(): Component should be a valid element type.')
2627
}
2728

2829
// short circuit noop values
@@ -157,8 +158,8 @@ export function createShorthand(Component, mapValueToProps, val, options = {}) {
157158
* @returns {function} A shorthand factory function waiting for `val` and `defaultProps`.
158159
*/
159160
export function createShorthandFactory(Component, mapValueToProps) {
160-
if (typeof Component !== 'function' && typeof Component !== 'string') {
161-
throw new Error('createShorthandFactory() Component must be a string or function.')
161+
if (!ReactIs.isValidElementType(Component)) {
162+
throw new Error('createShorthandFactory(): Component should be a valid element type.')
162163
}
163164

164165
return (val, options) => createShorthand(Component, mapValueToProps, val, options)

test/specs/commonTests/forwardsRef.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react'
2+
import { sandbox } from 'test/utils'
3+
4+
/**
5+
* Assert a Component correctly implements a shorthand create method.
6+
* @param {React.Component} Component The component to test
7+
*/
8+
export default function implementsCreateMethod(Component, options = {}) {
9+
describe('forwardsRef', () => {
10+
const { requiredProps = {}, tagName = 'div' } = options
11+
12+
it(`forwards ref to "${tagName}"`, () => {
13+
const ref = sandbox.spy()
14+
15+
mount(<Component {...requiredProps} ref={ref} />)
16+
17+
ref.should.have.been.calledOnce()
18+
ref.should.have.been.calledWithMatch({ tagName: tagName.toUpperCase() })
19+
})
20+
})
21+
}

test/specs/commonTests/hasValidTypings.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import _ from 'lodash'
22

33
import { customPropTypes } from 'src/lib'
44
import { componentInfoContext } from 'docs/src/utils'
5+
import { getComponentName, getComponentProps } from 'test/utils'
56
import { getNodes, getInterfaces, hasAnySignature, requireTs } from './tsHelpers'
67

78
const isShorthand = (propType) =>
@@ -27,8 +28,8 @@ const shorthandMap = {
2728
* @param {array} [options.ignoredTypingsProps=[]] Props that will be ignored in tests.
2829
* @param {Object} [options.requiredProps={}] Props required to render Component without errors or warnings.
2930
*/
30-
export default (Component, componentInfo, options = {}) => {
31-
const { displayName, repoPath } = componentInfoContext.fromComponent(Component)
31+
export default function hasValidTypings(Component, componentInfo, options = {}) {
32+
const { displayName, repoPath } = componentInfoContext.byDisplayName[getComponentName(Component)]
3233
const { ignoredTypingsProps = [], requiredProps } = options
3334

3435
const tsFile = repoPath.replace('src/', '').replace('.js', '.d.ts')
@@ -79,7 +80,7 @@ export default (Component, componentInfo, options = {}) => {
7980
})
8081

8182
it('match the typings interface', () => {
82-
const componentPropTypes = _.get(Component, 'propTypes')
83+
const componentPropTypes = getComponentProps(Component).propTypes
8384
const componentProps = _.keys(componentPropTypes)
8485
const interfaceProps = _.without(_.map(props, 'name'), ...ignoredTypingsProps)
8586

test/specs/commonTests/implementsCreateMethod.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import _ from 'lodash'
21
import React, { isValidElement } from 'react'
3-
import { consoleUtil } from 'test/utils'
2+
import { consoleUtil, getComponentName } from 'test/utils'
43

54
/**
65
* Assert a Component correctly implements a shorthand create method.
76
* @param {React.Component|Function} Component The component to test.
87
*/
9-
export default (Component) => {
10-
const { name } = _.get(Component, 'prototype.constructor.name')
11-
8+
export default function implementsCreateMethod(Component) {
129
describe('create shorthand method (common)', () => {
10+
const name = getComponentName(Component)
11+
1312
beforeEach(() => {
1413
// we generate prop values which may throw warnings
1514
// prevent failures due to console activity

test/specs/commonTests/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export forwardsRef from './forwardsRef'
2+
13
export hasSubcomponents from './hasSubcomponents'
24
export hasValidTypings from './hasValidTypings'
35
export hasUIClassName from './hasUIClassName'

test/specs/commonTests/isConformant.js

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import faker from 'faker'
22
import _ from 'lodash'
33
import React from 'react'
4+
import ReactIs from 'react-is'
45
import ReactDOMServer from 'react-dom/server'
56
import * as semanticUIReact from 'semantic-ui-react'
67

78
import { componentInfoContext } from 'docs/src/utils'
8-
import { assertBodyContains, consoleUtil, sandbox, syntheticEvent } from 'test/utils'
9-
import helpers from './commonHelpers'
9+
import {
10+
assertBodyContains,
11+
consoleUtil,
12+
getComponentName,
13+
getComponentProps,
14+
sandbox,
15+
syntheticEvent,
16+
} from 'test/utils'
1017
import hasValidTypings from './hasValidTypings'
1118

1219
/**
@@ -20,7 +27,7 @@ import hasValidTypings from './hasValidTypings'
2027
* @param {boolean} [options.rendersPortal=false] Does this component render a Portal powered component?
2128
* @param {Object} [options.requiredProps={}] Props required to render Component without errors or warnings.
2229
*/
23-
export default (Component, options = {}) => {
30+
export default function isConformant(Component, options = {}) {
2431
const {
2532
eventTargets = {},
2633
nestingLevel = 0,
@@ -29,25 +36,26 @@ export default (Component, options = {}) => {
2936
rendersFragmentByDefault = false,
3037
rendersPortal = false,
3138
} = options
32-
const { throwError } = helpers('isConformant', Component)
39+
const constructorName = getComponentName(Component)
3340

34-
const componentType = typeof Component
35-
36-
// make sure components are properly exported
37-
if (componentType !== 'function') {
38-
throwError(`Components should export a class or function, got: ${componentType}.`)
39-
}
40-
41-
// tests depend on Component constructor names, enforce them
42-
const constructorName = Component.prototype.constructor.name
43-
if (!constructorName) {
44-
throwError(
45-
[
46-
'Component is not a named function. This should help identify it:\n\n',
47-
`${ReactDOMServer.renderToStaticMarkup(<Component />)}`,
48-
].join(''),
41+
it('a valid component should be exported', () => {
42+
expect(ReactIs.isValidElementType(Component)).to.equal(
43+
true,
44+
`Components should export a class or function, got: ${typeof Component}.`,
4945
)
50-
}
46+
})
47+
48+
it('a component should be a function/class or "displayName" should be defined', () => {
49+
if (!constructorName) {
50+
throw new Error(
51+
[
52+
'Component is not a named function and does not have a "displayName".',
53+
'This should help identify it:\n\n',
54+
`${ReactDOMServer.renderToStaticMarkup(<Component {...requiredProps} />)}`,
55+
].join(''),
56+
)
57+
}
58+
})
5159

5260
const info = componentInfoContext.byDisplayName[constructorName]
5361

@@ -192,20 +200,22 @@ export default (Component, options = {}) => {
192200
}
193201

194202
describe('handles props', () => {
203+
const componentProps = getComponentProps(Component)
204+
195205
it('defines handled props in Component.handledProps', () => {
196-
Component.should.have.any.keys('handledProps')
197-
Component.handledProps.should.be.an('array')
206+
componentProps.should.have.any.keys('handledProps')
207+
componentProps.handledProps.should.be.an('array')
198208
})
199209

200210
it('Component.handledProps includes all handled props', () => {
201211
const computedProps = _.union(
202-
Component.autoControlledProps,
203-
_.keys(Component.defaultProps),
204-
_.keys(Component.propTypes),
212+
componentProps.autoControlledProps,
213+
_.keys(componentProps.defaultProps),
214+
_.keys(componentProps.propTypes),
205215
)
206216
const expectedProps = _.uniq(computedProps).sort()
207217

208-
Component.handledProps.should.to.deep.equal(
218+
componentProps.handledProps.should.to.deep.equal(
209219
expectedProps,
210220
'It seems that not all props were defined in Component.handledProps, you need to check that they are equal ' +
211221
'to the union of Component.autoControlledProps and keys of Component.defaultProps and Component.propTypes',
@@ -376,6 +386,7 @@ export default (Component, options = {}) => {
376386
})
377387
})
378388
}
389+
379390
// ----------------------------------------
380391
// Test typings
381392
// ----------------------------------------

test/specs/elements/Image/Image-test.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import _ from 'lodash'
2+
import faker from 'faker'
23
import React from 'react'
34

45
import Image from 'src/elements/Image/Image'
@@ -9,6 +10,21 @@ import * as common from 'test/specs/commonTests'
910

1011
describe('Image', () => {
1112
common.isConformant(Image)
13+
14+
common.forwardsRef(Image, { tagName: 'img' })
15+
common.forwardsRef(Image, {
16+
requiredProps: { as: 'div', children: <span /> },
17+
tagName: 'div',
18+
})
19+
common.forwardsRef(Image, {
20+
requiredProps: { as: 'div', content: <span /> },
21+
tagName: 'div',
22+
})
23+
common.forwardsRef(Image, {
24+
requiredProps: { label: faker.lorem.word() },
25+
tagName: 'img',
26+
})
27+
1228
common.hasSubcomponents(Image, [ImageGroup])
1329
common.hasUIClassName(Image)
1430
common.rendersChildren(Image)
@@ -40,10 +56,8 @@ describe('Image', () => {
4056
common.propValueOnlyToClassName(Image, 'size', SUI.SIZES)
4157

4258
describe('as', () => {
43-
it('renders an img tag', () => {
44-
shallow(<Image />)
45-
.type()
46-
.should.equal('img')
59+
it('renders "i" by default', () => {
60+
mount(<Image />).should.have.tagName('img')
4761
})
4862
})
4963

test/utils/getComponentName.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as ReactIs from 'react-is'
2+
3+
/**
4+
* Gets a proper `displayName` for a component.
5+
*
6+
* @param {React.ElementType} Component
7+
* @return {String}
8+
*/
9+
export default function getComponentName(Component) {
10+
if (Component.$$typeof === ReactIs.Memo) {
11+
return getComponentName(Component.type)
12+
}
13+
14+
if (Component.$$typeof === ReactIs.ForwardRef) {
15+
return Component.displayName
16+
}
17+
18+
return Component.prototype?.constructor?.name
19+
}

test/utils/getComponentProps.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as ReactIs from 'react-is'
2+
3+
/**
4+
* Gets proper props for a component.
5+
*
6+
* @param {React.ElementType} Component
7+
* @return {Object}
8+
*/
9+
export default function getComponentProps(Component) {
10+
if (Component.$$typeof === ReactIs.Memo) {
11+
return getComponentProps(Component.type)
12+
}
13+
14+
return {
15+
autoControlledProps: Component.autoControlledProps,
16+
defaultProps: Component.defaultProps,
17+
handledProps: Component.handledProps,
18+
propTypes: Component.propTypes,
19+
}
20+
}

test/utils/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export * from './assertNodeContains'
33
export assertWithTimeout from './assertWithTimeout'
44
export consoleUtil from './consoleUtil'
55
export domEvent from './domEvent'
6+
export getComponentName from './getComponentName'
7+
export getComponentProps from './getComponentProps'
68
export nestedShallow from './nestedShallow'
79
export sandbox from './sandbox'
810
export syntheticEvent from './syntheticEvent'

0 commit comments

Comments
 (0)