From 27c3dccf830265c28a85836b8bbaaa02fddd0c65 Mon Sep 17 00:00:00 2001 From: David Zukowski Date: Mon, 13 Nov 2017 08:53:47 -0600 Subject: [PATCH] fix(customPropTypes): improve perf of `suggest` --- src/lib/customPropTypes.js | 100 ++++++++++++++----------- test/specs/lib/customPropTypes-test.js | 3 +- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/lib/customPropTypes.js b/src/lib/customPropTypes.js index 776bcc0f11..35cb80859c 100644 --- a/src/lib/customPropTypes.js +++ b/src/lib/customPropTypes.js @@ -13,27 +13,31 @@ export const as = (...args) => PropTypes.oneOfType([ ])(...args) /* eslint-disable max-nested-callbacks */ -const findBestSuggestions = _.memoize((propValueWords, suggestions) => _.flow( - _.map((suggestion) => { - const suggestionWords = suggestion.split(' ') - - const propValueScore = _.flow( - _.map(x => _.map(y => leven(x, y), suggestionWords)), - _.map(_.min), - _.sum, - )(propValueWords) - - const suggestionScore = _.flow( - _.map(x => _.map(y => leven(x, y), propValueWords)), - _.map(_.min), - _.sum, - )(suggestionWords) - - return { suggestion, score: propValueScore + suggestionScore } - }), - _.sortBy(['score', 'suggestion']), - _.take(3), -)(suggestions)) +const findBestSuggestions = _.memoize((str, suggestions) => { + const propValueWords = str.split(' ') + + return _.flow( + _.map((suggestion) => { + const suggestionWords = suggestion.split(' ') + + const propValueScore = _.flow( + _.map(x => _.map(y => leven(x, y), suggestionWords)), + _.map(_.min), + _.sum, + )(propValueWords) + + const suggestionScore = _.flow( + _.map(x => _.map(y => leven(x, y), propValueWords)), + _.map(_.min), + _.sum, + )(suggestionWords) + + return { suggestion, score: propValueScore + suggestionScore } + }), + _.sortBy(['score', 'suggestion']), + _.take(3), + )(suggestions) +}) /* eslint-enable max-nested-callbacks */ /** @@ -42,33 +46,45 @@ const findBestSuggestions = _.memoize((propValueWords, suggestions) => _.flow( * Useful for very large lists of options (e.g. Icon name, Flag name, etc.) * @param {string[]} suggestions An array of allowed values. */ -export const suggest = suggestions => (props, propName, componentName) => { +export const suggest = (suggestions) => { if (!Array.isArray(suggestions)) { - throw new Error([ - 'Invalid argument supplied to suggest, expected an instance of array.', - ` See \`${propName}\` prop in \`${componentName}\`.`, - ].join('')) + throw new Error('Invalid argument supplied to suggest, expected an instance of array.') } - const propValue = props[propName] - // skip if prop is undefined or is included in the suggestions - if (_.isNil(propValue) || propValue === false || _.includes(propValue, suggestions)) return + // Convert the suggestions list into a hash map for O(n) lookup times. Ensure + // the words in each key are sorted alphabetically so that we have a consistent + // way of looking up a value in the map, i.e. we can sort the words in the + // incoming propValue and look that up without having to check all permutations. + const suggestionsLookup = suggestions.reduce((acc, key) => { + acc[key.split(' ').sort().join(' ')] = true + return acc + }, {}) + + return (props, propName, componentName) => { + const propValue = props[propName] + + // skip if prop is undefined or is included in the suggestions + if (!propValue || suggestionsLookup[propValue]) return + + // check if the words were correct but ordered differently. + // Since we're matching for classNames we need to allow any word order + // to pass validation, e.g. `left chevron` vs `chevron left`. + const propValueSorted = propValue.split(' ').sort().join(' ') + if (suggestionsLookup[propValueSorted]) return - // find best suggestions - const propValueWords = propValue.split(' ') - const bestMatches = findBestSuggestions(propValueWords, suggestions) + // find best suggestions + const bestMatches = findBestSuggestions(propValue, suggestions) - // skip if a match scored 0 - // since we're matching on words (classNames) this allows any word order to pass validation - // e.g. `left chevron` vs `chevron left` - if (bestMatches.some(x => x.score === 0)) return + // skip if a match scored 0 + if (bestMatches.some(x => x.score === 0)) return - return new Error([ - `Invalid prop \`${propName}\` of value \`${propValue}\` supplied to \`${componentName}\`.`, - `\n\nInstead of \`${propValue}\`, did you mean:`, - bestMatches.map(x => `\n - ${x.suggestion}`).join(''), - '\n', - ].join('')) + return new Error([ + `Invalid prop \`${propName}\` of value \`${propValue}\` supplied to \`${componentName}\`.`, + `\n\nInstead of \`${propValue}\`, did you mean:`, + bestMatches.map(x => `\n - ${x.suggestion}`).join(''), + '\n', + ].join('')) + } } /** diff --git a/test/specs/lib/customPropTypes-test.js b/test/specs/lib/customPropTypes-test.js index 2058e834b3..1d7394529f 100644 --- a/test/specs/lib/customPropTypes-test.js +++ b/test/specs/lib/customPropTypes-test.js @@ -2,8 +2,7 @@ import { customPropTypes } from 'src/lib' describe('suggest prop type', () => { it('should throw error when non-array argument given', () => { - const propType = customPropTypes.suggest('foo') - expect(() => propType({ name: 'bar' }, 'name', 'FooComponent')).to.throw( + expect(() => customPropTypes.suggest('foo')).to.throw( Error, /Invalid argument supplied to suggest, expected an instance of array./, )