Ajout du lint pour les hooks React

Comme recommandé dans la documentation des hooks React, ajout des deux
linters suivants : react-hooks/rules-of-hooks et react-hooks/exhaustive-deps

Mise à jour des composants, en particulier les useEffect pour y spécifier
toutes les dépendances.
pull/671/head
Maxime Quandalle 2019-09-15 22:51:13 +02:00
parent 3fbd94bc65
commit dbbb67ee7f
No known key found for this signature in database
GPG Key ID: 428641C03D29CA10
19 changed files with 122 additions and 114 deletions

View File

@ -1,7 +1,4 @@
rules:
linebreak-style:
- 2
- unix
quotes:
- 1 # While https://github.com/eslint/eslint/issues/9662#issuecomment-353958854 we don't enforce this
- single
@ -14,10 +11,13 @@ rules:
react/jsx-no-target-blank: 0
react/no-unescaped-entities: 0
react/display-name: 1
react-hooks/rules-of-hooks: error
react-hooks/exhaustive-deps: warn
parser: babel-eslint
plugins:
- react
- react-hooks
- flowtype
env:
browser: true

View File

@ -123,6 +123,7 @@
"eslint-config-prettier": "^4.0.0",
"eslint-plugin-flowtype": "^3.2.1",
"eslint-plugin-react": "^7.12.4",
"eslint-plugin-react-hooks": "^2.0.1",
"express": "^4.16.3",
"file-loader": "^1.1.11",
"flow-bin": "^0.92.0",

View File

@ -8,6 +8,7 @@ import Overlay from './Overlay'
// Il suffit à la section d'appeler une fonction fournie en lui donnant du JSX
export let AttachDictionary = dictionary => Decorated =>
function withDictionary(props) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [{ explanation, term }, setState] = useState({
term: null,
explanation: null

View File

@ -1,5 +1,5 @@
import classnames from 'classnames'
import React, { useEffect, useRef, useState } from 'react'
import React, { useRef, useState } from 'react'
import NumberFormat from 'react-number-format'
import { debounce } from '../../utils'
import './CurrencyInput.css'
@ -22,23 +22,27 @@ let currencyFormat = language => ({
})
export default function CurrencyInput({
value: valueArg,
value: valueProp = '',
debounce: debounceTimeout,
onChange,
language,
className,
...forwardedProps
}) {
const [currentValue, setCurrentValue] = useState(valueArg)
const [initialValue] = useState(valueArg)
// When the component is rendered with a new "value" argument, we update our local state
useEffect(() => {
setCurrentValue(valueArg)
}, [valueArg])
const nextValue = useRef(null)
const [initialValue, setInitialValue] = useState(valueProp)
const [currentValue, setCurrentValue] = useState(valueProp)
const onChangeDebounced = useRef(
debounceTimeout ? debounce(debounceTimeout, onChange) : onChange
)
// We need some mutable reference because the <NumberFormat /> component doesn't provide
// the DOM `event` in its custom `onValueChange` handler
const nextValue = useRef(null)
// When the component is rendered with a new "value" prop, we reset our local state
if (valueProp !== initialValue) {
setCurrentValue(valueProp)
setInitialValue(valueProp)
}
const handleChange = event => {
// Only trigger the `onChange` event if the value has changed -- and not
@ -61,19 +65,17 @@ export default function CurrencyInput({
thousandSeparator,
decimalSeparator
} = currencyFormat(language)
// We display negative numbers iff this was the provided value (but we allow the user to enter them)
// We display negative numbers iff this was the provided value (but we disallow the user to enter them)
const valueHasChanged = currentValue !== initialValue
// Autogrow the input
const valueLength = (currentValue || '').toString().length
const valueLength = currentValue.toString().length
const width = `${5 + (valueLength - 5) * 0.75}em`
return (
<div
className={classnames(className, 'currencyInput__container')}
{...(valueLength > 5
? { style: { width: `${5 + (valueLength - 5) * 0.75}em` } }
: {})}>
{...(valueLength > 5 ? { style: { width } } : {})}>
{isCurrencyPrefixed && '€'}
<NumberFormat
{...forwardedProps}
@ -84,10 +86,10 @@ export default function CurrencyInput({
inputMode="numeric"
onValueChange={({ value }) => {
setCurrentValue(value)
nextValue.current = value.toString().replace(/^\-/, '')
nextValue.current = value.toString().replace(/^-/, '')
}}
onChange={handleChange}
value={(currentValue || '').toString().replace('.', decimalSeparator)}
value={currentValue.toString().replace('.', decimalSeparator)}
/>
{!isCurrencyPrefixed && <>&nbsp;</>}
</div>

View File

@ -4,7 +4,8 @@ import './PercentageField.css'
export default function PercentageField({ onChange, value, debounce }) {
const [localValue, setLocalValue] = useState(value)
const debouncedOnChange = useCallback(
debounce ? debounce(debounce, onChange) : onChange
debounce ? debounce(debounce, onChange) : onChange,
[debounce, onChange]
)
return (

View File

@ -1,5 +1,5 @@
import { findRuleByDottedName } from 'Engine/rules'
import React, { useEffect } from 'react'
import React, { useCallback, useEffect } from 'react'
import { Trans } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import {
@ -15,17 +15,20 @@ export default function PeriodSwitch() {
const initialPeriod = useSelector(
state => state.simulation?.config?.situation?.période
)
const currentPeriod = situation.période
useEffect(() => {
!currentPeriod && updatePeriod(initialPeriod || 'année')
}, [])
const currentPeriod = situation.période
const updatePeriod = toPeriod => {
const needConversion = Object.keys(situation).filter(dottedName => {
const rule = findRuleByDottedName(rules, dottedName)
return rule?.période === 'flexible'
})
dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConversion })
}
}, [currentPeriod, initialPeriod, updatePeriod])
const updatePeriod = useCallback(
toPeriod => {
const needConversion = Object.keys(situation).filter(dottedName => {
const rule = findRuleByDottedName(rules, dottedName)
return rule?.période === 'flexible'
})
dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConversion })
},
[dispatch, rules, situation]
)
const periods = ['mois', 'année']
return (

View File

@ -2,7 +2,7 @@ import withSitePaths from 'Components/utils/withSitePaths'
import { encodeRuleName } from 'Engine/rules.js'
import Fuse from 'fuse.js'
import { compose, pick, sortBy } from 'ramda'
import React, { useRef, useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import Highlighter from 'react-highlight-words'
import { useTranslation } from 'react-i18next'
import { Link, Redirect } from 'react-router-dom'
@ -19,28 +19,23 @@ function SearchBar({
const [inputValue, setInputValue] = useState(null)
const [selectedOption, setSelectedOption] = useState(null)
const inputElementRef = useRef()
const fuse = useRef()
// This operation is expensive, we don't want to do it everytime we re-render, so we cache its result
const fuse = useMemo(() => {
const list = rules.map(
pick(['title', 'espace', 'description', 'name', 'dottedName'])
)
const options = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'espace', weight: 0.2 },
{ name: 'description', weight: 0.2 }
]
}
return new Fuse(list, options)
}, [rules])
const { i18n } = useTranslation()
const options = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'espace', weight: 0.2 },
{ name: 'description', weight: 0.2 }
]
}
if (!fuse.current) {
// This operation is expensive, we don't want to do it everytime we re-render, so we cache its result in a reference
fuse.current = new Fuse(
rules.map(pick(['title', 'espace', 'description', 'name', 'dottedName'])),
options
)
}
const handleChange = selectedOption => {
setSelectedOption(selectedOption)
}
const renderOption = ({ title, dottedName }) => (
<span>
<Highlighter searchWords={[inputValue]} textToHighlight={title} />
@ -49,7 +44,7 @@ function SearchBar({
</span>
</span>
)
const filterOptions = (options, filter) => fuse.current.search(filter)
const filterOptions = (options, filter) => fuse.search(filter)
if (selectedOption != null) {
finallyCallback && finallyCallback()
@ -68,8 +63,8 @@ function SearchBar({
<>
<Select
value={selectedOption && selectedOption.dottedName}
onChange={handleChange}
onInputChange={inputValue => setInputValue(inputValue)}
onChange={setSelectedOption}
onInputChange={setInputValue}
valueKey="dottedName"
labelKey="title"
options={rules}

View File

@ -14,6 +14,13 @@ export default compose(
)(function SearchButton({ flatRules, invisibleButton }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const handleKeyDown = e => {
if (!(e.ctrlKey && e.key === 'k')) return
setVisible(true)
e.preventDefault()
e.stopPropagation()
return false
}
window.addEventListener('keydown', handleKeyDown)
return () => {
@ -21,14 +28,6 @@ export default compose(
}
}, [])
const handleKeyDown = e => {
if (!(e.ctrlKey && e.key === 'k')) return
setVisible(true)
e.preventDefault()
e.stopPropagation()
return false
}
const close = () => setVisible(false)
return visible ? (

View File

@ -41,8 +41,14 @@ export default compose(
const situation = useSituation()
const dispatch = useDispatch()
const targets =
analysis?.targets.filter(
t =>
!secondaryObjectives.includes(t.dottedName) &&
t.dottedName !== 'contrat salarié . aides employeur'
) || []
useEffect(() => {
let targets = getTargets()
// Initialize defaultValue for target that can't be computed
targets
.filter(
@ -64,22 +70,10 @@ export default compose(
)
})
if (initialRender) {
setInitialRender(false)
}
setInitialRender(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const getTargets = () => {
if (!analysis) return []
return analysis.targets.filter(
t =>
!secondaryObjectives.includes(t.dottedName) &&
t.dottedName !== 'contrat salarié . aides employeur'
)
}
let targets = getTargets()
return (
<div id="targetSelection">
{(typeof objectifs[0] === 'string' ? [{ objectifs }] : objectifs).map(

View File

@ -38,10 +38,13 @@ export default compose(
value: currentValue
}) {
const [touched, setTouched] = useState(false)
const onChange = useCallback(value => {
setFormValue(value)
setTouched(true)
})
const onChange = useCallback(
value => {
setFormValue(value)
setTouched(true)
},
[setFormValue]
)
const renderBinaryQuestion = () => {
return (

View File

@ -1,28 +1,29 @@
import React, { useEffect } from 'react'
import React, { useCallback, useEffect } from 'react'
import { Trans } from 'react-i18next'
export default function SendButton({ disabled, submit }) {
const getAction = useCallback(cause => (!disabled ? submit(cause) : null), [
disabled,
submit
])
useEffect(() => {
const handleKeyDown = ({ key }) => {
if (key !== 'Enter') return
getAction('enter')
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [])
}, [getAction])
const getAction = () => {
return cause => (!disabled ? submit(cause) : null)
}
const handleKeyDown = ({ key }) => {
if (key !== 'Enter') return
getAction()('enter')
}
return (
<button
className="ui__ button plain"
css="margin-left: 1.2rem"
disabled={disabled}
onClick={() => getAction()('accept')}>
onClick={() => getAction('accept')}>
<span className="text">
<Trans>Suivant</Trans>
</span>

View File

@ -17,7 +17,7 @@ export default function AnimatedTargetValue({ value }: Props) {
}
setDifference((value || 0) - (previousValue || 0))
setPreviousValue(value)
}, [value])
}, [previousValue, value])
const { i18n } = useTranslation()
const format = value => {

View File

@ -131,7 +131,10 @@ export function appear({
delay = 0,
style
}) {
// TODO: We should rename this function Appear
// eslint-disable-next-line react-hooks/rules-of-hooks
const [show, setShow] = useState(unless)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
window.setTimeout(() => setShow(true), 0)
}, [])

View File

@ -79,7 +79,7 @@ export default function uniroot(
if (
p < 0.75 * cb * q - Math.abs(tol_act * q) / 2 &&
p < Math.abs(prev_step * q / 2)
p < Math.abs((prev_step * q) / 2)
) {
// If (b + p / q) falls in [b,c] and isn't too large it is accepted
new_step = p / q

View File

@ -58,7 +58,7 @@ const middlewares = [
function InFranceRoute({ basename, language }) {
useEffect(() => {
setToSessionStorage('lang', language)
}, [])
}, [language])
const paths = constructLocalizedSitePath(language)
return (
<Provider

View File

@ -3,7 +3,7 @@
import classnames from 'classnames'
import withTracker from 'Components/utils/withTracker'
import { compose } from 'ramda'
import React, { useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { withRouter } from 'react-router'
import backSvg from './back.svg'
import mobileMenuSvg from './mobile-menu.svg'
@ -19,10 +19,6 @@ type Props = OwnProps & {
tracker: Tracker,
location: Location
}
type State = {
opened: boolean,
sticky: boolean
}
const bigScreen = window.matchMedia('(min-width: 1500px)')
const isParent = (parentNode, children) => {
@ -35,13 +31,19 @@ const isParent = (parentNode, children) => {
return isParent(parentNode, children.parentNode)
}
function SideBar({ location, tracker, children }) {
function SideBar({ location, tracker, children }: Props) {
const [opened, setOpened] = useState(false)
const [sticky, setSticky] = useState(bigScreen.matches)
const [previousLocation, setPreviousLocation] = useState(location)
const ref = useRef()
useEffect(() => {
const handleClick = event => {
if (!sticky && !isParent(ref.current, event.target) && opened) {
handleClose()
}
}
window.addEventListener('click', handleClick)
bigScreen.addListener(handleMediaQueryChange)
@ -49,27 +51,22 @@ function SideBar({ location, tracker, children }) {
window.removeEventListener('click', handleClick)
bigScreen.removeListener(handleMediaQueryChange)
}
}, [opened, sticky])
}, [handleClose, opened, sticky])
useEffect(() => {
if (!sticky && previousLocation !== location) {
setOpened(false)
}
setPreviousLocation(location)
})
}, [sticky, previousLocation, location])
const handleClick = event => {
if (!sticky && !isParent(ref.current, event.target) && opened) {
handleClose()
}
}
const handleMediaQueryChange = () => {
setSticky(bigScreen.matches)
}
const handleClose = () => {
const handleClose = useCallback(() => {
tracker.push(['trackEvent', 'Sidebar', 'close'])
setOpened(false)
}
}, [tracker])
const handleOpen = () => {
tracker.push(['trackEvent', 'Sidebar', 'open'])
setOpened(true)

View File

@ -23,6 +23,7 @@ export default function IntegrationTest() {
script.dataset.couleur = colour
domNode.current.innerHTML = ''
domNode.current.appendChild(script)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version])
return (
<>

View File

@ -1,7 +1,7 @@
import LangSwitcher from 'Components/LangSwitcher'
import marianneSvg from 'Images/marianne.svg'
import urssafSvg from 'Images/urssaf.svg'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { Link } from 'react-router-dom'
@ -9,8 +9,10 @@ import screenfull from 'screenfull'
import { isIE } from '../../../../utils'
export default function IframeFooter() {
const [isFullscreen, setIsFullscreen] = useState(screenfull.isFullscreen)
useEffect(() => {
screenfull.enabled && screenfull.onchange(() => this.forceUpdate())
screenfull.enabled &&
screenfull.onchange(() => setIsFullscreen(screenfull.isFullscreen))
}, [])
return (
@ -44,7 +46,7 @@ export default function IframeFooter() {
justifyContent: 'space-between'
}}>
<LangSwitcher className="ui__ button simple" />
{screenfull.enabled && !screenfull.isFullscreen && !isIE() && (
{screenfull.enabled && !isFullscreen && !isIE() && (
<button
className="ui__ button small"
onClick={() => {

View File

@ -3553,6 +3553,11 @@ eslint-plugin-flowtype@^3.2.1:
dependencies:
lodash "^4.17.15"
eslint-plugin-react-hooks@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.0.1.tgz#e898ec26a0a335af6f7b0ad1f0bedda7143ed756"
integrity sha512-xir+3KHKo86AasxlCV8AHRtIZPHljqCRRUYgASkbatmt0fad4+5GgC7zkT7o/06hdKM6MIwp8giHVXqBPaarHQ==
eslint-plugin-react@^7.12.4:
version "7.14.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz#911030dd7e98ba49e1b2208599571846a66bdf13"