Skip to content

Commit 3c93639

Browse files
committed
update: large internal rewrite that accomplishes: 1). large performance boost; 2). reduction in code/bundle size of package. Changes focused on removing logic from useEffects and moving it to memoized functions (that way updates happen quicker and are not occurring after previous renders - and causing additional renders). Additionally, dependency lists were trimmed by making use of refs that keep up to date values between renders. More in depth analysis will be included with release notes for the upcoming v4.0.0 release.
1 parent fb32dd9 commit 3c93639

21 files changed

+144
-170
lines changed

__stories__/index.stories.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ export const Virtualization = () => {
707707
const [options, setOptions] = useState<Option[]>([]);
708708
const [optionsCount, setOptionsCount] = useState(100);
709709

710-
const optionCountList = [100, 1000, 5000, 25000, 50000];
710+
const optionCountList = [100, 1000, 10000, 25000, 50000, 100000];
711711

712712
useUpdateEffect(() => {
713713
selectRef.current?.clearValue();
@@ -752,18 +752,22 @@ export const Virtualization = () => {
752752
memoization (testing {'&'} debugging becomes much easier as well).
753753
</Li>
754754
</List>
755-
<em>Note: </em>The only time any noticeable performance degradation will
756-
be observed is during search input updates when the <code>options</code>{' '}
757-
count reaches the high tens of thousands. To work around this, the{' '}
758-
<code>inputDelay</code> (number in milliseconds) can be set to debounce
759-
the input value. That way, the <code>menuOptions</code> will not be
760-
recalculated on every keystroke.
755+
<em>Note: </em>Potential performance degradation could be encountered during input
756+
value mutations when the <code>options</code> count reaches the high tens of thousands.
757+
To work around this, the <code>inputDelay</code> (in milliseconds) can be set to debounce
758+
the input value. That way, the <code>menuOptions</code> will not be recalculated on every
759+
keystroke. Although this is an extreme edge case, optimizations have been implemented to
760+
handle such with ease. As proof, 50k and 100k option counts have been included in this
761+
stress-test demo - but again, data sets this large should not be worked with in memory.
762+
Instead, prefer to fetch subsets from a remote data store as needed. For example, using
763+
the <code>async</code> functionality or custom logic in a parent component that accomplishes
764+
something similar.
761765
</ListWrapper>
762766
<SubTitle>Demo</SubTitle>
763767
<Hr />
764768
<Card>
765769
<CardHeader>
766-
<Label>Options Count</Label>
770+
<Label>Number of Options</Label>
767771
<Buttons>
768772
{optionCountList.map((count) => (
769773
<OptionsCountButton
@@ -985,7 +989,7 @@ export const Async = () => {
985989
<TextHeader>inputDelay?: number</TextHeader> - As mentioned above, this can be
986990
set to a positive integer in order to debounce updates to the search input value
987991
following input change events. This property directly maps to the <code>delay</code> in
988-
milliconds passed to the <code>setTimeout</code> method.
992+
milliseconds passed to the <code>setTimeout</code> method.
989993
</Li>
990994
<Li>
991995
<TextHeader>isLoading?: boolean</TextHeader> - When true, a loading animation will

babel.config.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ module.exports = (api) => {
99
];
1010

1111
const plugins = [
12-
['@babel/plugin-proposal-optional-chaining', {loose: true}],
13-
['@babel/plugin-proposal-nullish-coalescing-operator', {loose: true}],
1412
[
1513
'babel-plugin-styled-components',
1614
{

package.json

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@
4949
"devDependencies": {
5050
"@babel/cli": "^7.19.3",
5151
"@babel/core": "^7.20.2",
52-
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
53-
"@babel/plugin-proposal-optional-chaining": "^7.18.9",
5452
"@babel/plugin-transform-runtime": "^7.19.6",
5553
"@babel/preset-env": "^7.20.2",
5654
"@babel/preset-react": "^7.18.6",
@@ -67,14 +65,14 @@
6765
"@testing-library/jest-dom": "^5.16.5",
6866
"@testing-library/react": "^13.4.0",
6967
"@testing-library/user-event": "^14.4.3",
70-
"@types/jest": "^29.2.2",
68+
"@types/jest": "^29.2.3",
7169
"@types/node": "^18.11.9",
7270
"@types/react": "^18.0.25",
73-
"@types/react-dom": "^18.0.8",
71+
"@types/react-dom": "^18.0.9",
7472
"@types/react-window": "^1.8.5",
7573
"@types/styled-components": "^5.1.26",
76-
"@typescript-eslint/eslint-plugin": "^5.42.1",
77-
"@typescript-eslint/parser": "^5.42.1",
74+
"@typescript-eslint/eslint-plugin": "^5.43.0",
75+
"@typescript-eslint/parser": "^5.43.0",
7876
"babel-jest": "^29.3.1",
7977
"babel-loader": "^9.1.0",
8078
"babel-plugin-styled-components": "^2.0.7",
@@ -100,7 +98,7 @@
10098
"rollup": "^3.3.0",
10199
"rollup-plugin-terser": "^7.0.2",
102100
"styled-components": "^5.3.6",
103-
"typescript": "^4.8.4",
101+
"typescript": "^4.9.3",
104102
"webpack": "^5.75.0"
105103
},
106104
"peerDependencies": {

rollup.config.mjs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,9 @@ const babelPlugin = (useESModules) => {
5050
babelHelpers: 'runtime',
5151
exclude: 'node_modules/**',
5252
extensions: [...DEFAULT_EXTENSIONS, '.ts', '.tsx'],
53-
presets: [
54-
['@babel/preset-env', {targets, loose: true}],
55-
'@babel/preset-react'
56-
],
53+
presets: [['@babel/preset-env', {targets, loose: true}], '@babel/preset-react'],
5754
plugins: [
5855
['@babel/plugin-transform-runtime', {useESModules}],
59-
['@babel/plugin-proposal-optional-chaining', {loose: true}],
60-
['@babel/plugin-proposal-nullish-coalescing-operator', {loose: true}],
6156
[
6257
'babel-plugin-styled-components',
6358
{
@@ -66,10 +61,10 @@ const babelPlugin = (useESModules) => {
6661
minify: true,
6762
fileName: false,
6863
displayName: true,
69-
transpileTemplateLiterals: true
64+
transpileTemplateLiterals: true,
7065
},
7166
],
72-
]
67+
],
7368
});
7469
};
7570

src/Select.tsx

Lines changed: 43 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
FilterMatchEnum,
1919
OptionIndexEnum,
2020
MenuPositionEnum,
21-
FunctionDefaults,
21+
FUNCTION_DEFAULTS,
2222
EMPTY_ARRAY,
2323
DEFAULT_THEME,
2424
SELECT_WRAPPER_ATTRS,
@@ -31,11 +31,6 @@ import {
3131
MENU_MAX_HEIGHT_DEFAULT,
3232
CONTROL_CONTAINER_TESTID
3333
} from './constants';
34-
import type { FixedSizeList } from 'react-window';
35-
import styled, { css, ThemeProvider, type DefaultTheme } from 'styled-components';
36-
import { Menu, Value, AriaLiveRegion, AutosizeInput, IndicatorIcons } from './components';
37-
import { useDebounce, useCallbackRef, useMenuOptions, useMountEffect, useUpdateEffect, useMenuPositioner } from './hooks';
38-
import { isBoolean, isFunction, isPlainObject, mergeDeep, suppressEvent, normalizeValue, IS_TOUCH_DEVICE, isArrayWithLength } from './utils';
3934
import type {
4035
Theme,
4136
SelectRef,
@@ -52,6 +47,11 @@ import type {
5247
OptionValueCallback,
5348
RenderLabelCallback
5449
} from './types';
50+
import type { FixedSizeList } from 'react-window';
51+
import styled, { css, ThemeProvider, type DefaultTheme } from 'styled-components';
52+
import { Menu, Value, AriaLiveRegion, AutosizeInput, IndicatorIcons } from './components';
53+
import { useDebounce, useLatestRef, useCallbackRef, useMenuOptions, useMountEffect, useUpdateEffect, useMenuPositioner } from './hooks';
54+
import { isBoolean, isFunction, isPlainObject, mergeDeep, suppressEvent, normalizeValue, IS_TOUCH_DEVICE, isArrayWithLength } from './utils';
5555

5656
type SelectProps = Readonly<{
5757
async?: boolean;
@@ -237,13 +237,6 @@ const Select = forwardRef<SelectRef, SelectProps>((
237237
},
238238
ref: Ref<SelectRef>
239239
) => {
240-
// Instance prop refs (primitive/function type)
241-
const menuOpenRef = useRef<boolean>(false);
242-
const prevMenuOptionsLength = useRef<number>();
243-
const onChangeEvtValue = useRef<boolean>(false);
244-
const onSearchChangeIsFunc = useRef<boolean>(isFunction(onSearchChange));
245-
const onOptionChangeIsFunc = useRef<boolean>(isFunction(onOptionChange));
246-
247240
// DOM element refs
248241
const listRef = useRef<FixedSizeList | null>(null);
249242
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -265,13 +258,22 @@ const Select = forwardRef<SelectRef, SelectProps>((
265258
}, [themeConfig]);
266259

267260
// Memoized callback functions referencing optional function properties on Select.tsx
268-
const getOptionLabelFn = useMemo<OptionLabelCallback>(() => getOptionLabel || FunctionDefaults.OPTION_LABEL, [getOptionLabel]);
269-
const getOptionValueFn = useMemo<OptionValueCallback>(() => getOptionValue || FunctionDefaults.OPTION_VALUE, [getOptionValue]);
261+
const getOptionLabelFn = useMemo<OptionLabelCallback>(() => getOptionLabel || FUNCTION_DEFAULTS.optionLabel, [getOptionLabel]);
262+
const getOptionValueFn = useMemo<OptionValueCallback>(() => getOptionValue || FUNCTION_DEFAULTS.optionValue, [getOptionValue]);
270263
const renderOptionLabelFn = useMemo<RenderLabelCallback>(() => renderOptionLabel || getOptionLabelFn, [renderOptionLabel, getOptionLabelFn]);
271264

272265
// Custom hook abstraction that debounces search input value (opt-in)
273266
const debouncedInputValue = useDebounce<string>(inputValue, inputDelay);
274267

268+
// Custom ref objects
269+
const onSearchChangeRef = useCallbackRef(onSearchChange);
270+
const onOptionChangeRef = useCallbackRef(onOptionChange);
271+
const onSearchChangeIsFunc = useLatestRef<boolean>(isFunction(onSearchChange));
272+
const onOptionChangeIsFunc = useLatestRef<boolean>(isFunction(onOptionChange));
273+
const menuOpenRef = useLatestRef<boolean>(menuOpen);
274+
const onChangeEvtValue = useRef<boolean>(false);
275+
const prevMenuOptionsLength = useRef<number>();
276+
275277
// If initialValue is specified attempt to initialize, otherwise default to []
276278
const [selectedOption, setSelectedOption] = useState<SelectedOption[]>(
277279
() => normalizeValue(
@@ -314,9 +316,6 @@ const Select = forwardRef<SelectRef, SelectProps>((
314316
scrollMenuIntoView,
315317
);
316318

317-
const onSearchChangeRef = useCallbackRef(onSearchChange);
318-
const onOptionChangeRef = useCallbackRef(onOptionChange);
319-
320319
const blurInput = (): void => inputRef.current?.blur();
321320
const focusInput = (): void => inputRef.current?.focus();
322321
const scrollToItemIndex = (idx: number): void => listRef.current?.scrollToItem(idx);
@@ -386,8 +385,8 @@ const Select = forwardRef<SelectRef, SelectProps>((
386385
setFocusedOption(FOCUSED_OPTION_DEFAULT);
387386
},
388387
setValue: (option?: OptionData) => {
389-
const normalizedOptions = normalizeValue(option, getOptionValueFn, getOptionLabelFn);
390-
setSelectedOption(normalizedOptions);
388+
const normalizedOpts = normalizeValue(option, getOptionValueFn, getOptionLabelFn);
389+
setSelectedOption(normalizedOpts);
391390
},
392391
toggleMenu: (state?: boolean) => {
393392
if (state === true || (state === undefined && !menuOpenRef.current)) {
@@ -402,31 +401,15 @@ const Select = forwardRef<SelectRef, SelectProps>((
402401
);
403402

404403
/**
405-
* useMountEffect:
404+
* useMountEffect
406405
* If autoFocus = true, focus the control following initial mount.
407406
*/
408407
useMountEffect(() => {
409408
autoFocus && focusInput();
410409
});
411410

412411
/**
413-
* Execute every render - these ref boolean flags are used to determine if functions
414-
* ..are defined inside of a callback wrapper returned from 'useCallbackRef' custom hook
415-
*/
416-
useEffect(() => {
417-
onSearchChangeIsFunc.current = isFunction(onSearchChange);
418-
onOptionChangeIsFunc.current = isFunction(onOptionChange);
419-
});
420-
421-
/**
422-
* Write value of 'menuOpen' to ref object.
423-
* Prevent extraneous state update calls/rerenders.
424-
*/
425-
useEffect(() => {
426-
menuOpenRef.current = menuOpen;
427-
}, [menuOpen]);
428-
429-
/**
412+
* useEffect
430413
* If control recieves focus & openMenuOnFocus = true, open menu
431414
*/
432415
useEffect(() => {
@@ -436,6 +419,7 @@ const Select = forwardRef<SelectRef, SelectProps>((
436419
}, [isFocused, openMenuOnFocus, openMenuAndFocusOption]);
437420

438421
/**
422+
* useEffect
439423
* If 'onSearchChange' function is defined, run as callback when the stateful debouncedInputValue
440424
* updates check if onChangeEvtValue ref is set true, which indicates the inputValue change was triggered by input change event
441425
*/
@@ -447,7 +431,7 @@ const Select = forwardRef<SelectRef, SelectProps>((
447431
}, [onSearchChangeRef, debouncedInputValue]);
448432

449433
/**
450-
* useUpdateEffect:
434+
* useUpdateEffect
451435
* Handle passing 'selectedOption' value(s) to onOptionChange callback function prop (if defined)
452436
*/
453437
useUpdateEffect(() => {
@@ -463,23 +447,27 @@ const Select = forwardRef<SelectRef, SelectProps>((
463447
}, [onOptionChangeRef, isMulti, selectedOption]);
464448

465449
/**
466-
* useUpdateEffect:
450+
* useUpdateEffect
467451
* Handle clearing focused option if menuOptions array has 0 length;
468452
* Handle menuOptions changes - conditionally focus first option and do scroll to first option;
469453
* Handle reseting scroll pos to first item after the previous search returned zero results (use prevMenuOptionsLen)
454+
* ...or if there is a selected item and menuOptions is restored to include it, give it focus
470455
*/
471456
useUpdateEffect(() => {
472-
const { length } = menuOptions;
473-
const inputChanged = length > 0 && (async || length !== options.length || prevMenuOptionsLength.current === 0);
457+
const curLength = menuOptions.length;
458+
const { current: prevLength } = prevMenuOptionsLength;
459+
const inputChanged = curLength > 0 && (async || curLength !== options.length || prevLength === 0);
460+
const menuOpenAndOptionsGrew = menuOpenRef.current && prevLength !== undefined && prevLength < curLength;
474461

475-
if (length === 0) {
462+
if (curLength === 0) {
476463
setFocusedOption(FOCUSED_OPTION_DEFAULT);
477-
} else if (length === 1 || inputChanged) {
478-
scrollToItemIndex(0);
479-
setFocusedOption({ index: 0, ...menuOptions[0] });
464+
} else if (curLength === 1 || inputChanged || menuOpenAndOptionsGrew) {
465+
const index = Math.max(0, menuOptions.findIndex((x) => x.isSelected));
466+
scrollToItemIndex(index);
467+
setFocusedOption({ index, ...menuOptions[index] });
480468
}
481469

482-
prevMenuOptionsLength.current = length;
470+
prevMenuOptionsLength.current = curLength;
483471
}, [async, options, menuOptions]);
484472

485473
const selectOptionFromFocused = (): void => {
@@ -543,11 +531,15 @@ const Select = forwardRef<SelectRef, SelectProps>((
543531

544532
switch (key) {
545533
case 'ArrowDown': {
546-
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.DOWN) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
534+
menuOpen
535+
? focusOptionOnArrowKey(OptionIndexEnum.DOWN)
536+
: openMenuAndFocusOption(OptionIndexEnum.FIRST);
547537
break;
548538
}
549539
case 'ArrowUp': {
550-
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.UP) : openMenuAndFocusOption(OptionIndexEnum.LAST);
540+
menuOpen
541+
? focusOptionOnArrowKey(OptionIndexEnum.UP)
542+
: openMenuAndFocusOption(OptionIndexEnum.LAST);
551543
break;
552544
}
553545
case 'ArrowLeft':
@@ -636,7 +628,6 @@ const Select = forwardRef<SelectRef, SelectProps>((
636628
if (!isFocused) focusInput();
637629

638630
const isNotInput = (e.target as HTMLElement).nodeName !== 'INPUT';
639-
640631
if (!menuOpen) {
641632
openMenuOnClick && openMenuAndFocusOption(OptionIndexEnum.FIRST);
642633
} else if (isNotInput) {
@@ -661,9 +652,8 @@ const Select = forwardRef<SelectRef, SelectProps>((
661652

662653
const handleOnInputChange = useCallback((e: FormEvent<HTMLInputElement>): void => {
663654
onChangeEvtValue.current = true;
664-
const curVal = e.currentTarget.value;
665-
onInputChange?.(curVal);
666-
setInputValue(curVal);
655+
onInputChange?.(e.currentTarget.value);
656+
setInputValue(e.currentTarget.value);
667657
setMenuOpen(true);
668658
}, [onInputChange]);
669659

src/components/AutosizeInput/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import React, {
1212
type FocusEventHandler,
1313
} from 'react';
1414

15+
type InputProps = Readonly<{
16+
isInvalid: boolean
17+
}>;
18+
1519
type AutosizeInputProps = Readonly<{
1620
id?: string;
1721
readOnly: boolean;
@@ -25,7 +29,7 @@ type AutosizeInputProps = Readonly<{
2529
onChange: FormEventHandler<HTMLInputElement>;
2630
}>;
2731

28-
const INPUT_MIN_WIDTH_PX = 15;
32+
const INPUT_MIN_WIDTH_PX = 20;
2933

3034
const INPUT_FONT_STYLE = css`
3135
font-size: inherit;
@@ -45,7 +49,7 @@ const SizerDiv = styled.div`
4549
${({ theme }) => theme.input.css}
4650
`;
4751

48-
const Input = styled.input.attrs(AUTOSIZE_INPUT_ATTRS)<{ isInvalid: boolean }>`
52+
const Input = styled.input.attrs(AUTOSIZE_INPUT_ATTRS)<InputProps>`
4953
border: 0;
5054
outline: 0;
5155
padding: 0;

src/components/IndicatorIcons/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type IndicatorIconsProps = Readonly<{
1919
onCaretMouseDown?: MouseOrTouchEventHandler;
2020
}>;
2121

22+
type CaretProps = Pick<IndicatorIconsProps, 'menuOpen' | 'isInvalid'>;
23+
2224
const IndicatorIconsWrapper = styled.div`
2325
display: flex;
2426
flex-shrink: 0;
@@ -50,7 +52,7 @@ const Separator = styled.div`
5052
background-color: ${({ theme }) => theme.color.iconSeparator || theme.color.border};
5153
`;
5254

53-
const Caret = styled.div<Pick<IndicatorIconsProps, 'menuOpen' | 'isInvalid'>>`
55+
const Caret = styled.div<CaretProps>`
5456
transition: ${({ theme }) => theme.icon.caret.transition};
5557
border-top: ${({ theme }) => theme.icon.caret.size} dashed;
5658
border-left: ${({ theme }) => theme.icon.caret.size} solid transparent;

0 commit comments

Comments
 (0)