Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Windowing for Select & SelectMulti #870

Merged
merged 22 commits into from
May 5, 2020
Merged

Conversation

mdodgelooker
Copy link
Contributor

@mdodgelooker mdodgelooker commented May 3, 2020

✨ Changes

  • Filtering in the core product often requires having up to 1,000 options in a dropdown list
  • With this PR, if options >= 100, only the ones currently visible in the scroll window will render
  • windowedOptions prop overrides the above (either true for < 100 or false for >= 100)
  • This impacted various Combobox behaviors including keyboard navigating through options via the arrow keys and scrolling to the current value on open
    • Select/useWindowedOptions.tsx manages these behaviors
    • utils/getWindowedListBoundaries.ts is generic and may be used for windowing in other list components
  • Grouped options not supported

✅ Requirements

  • Includes test coverage for all changes
  • Build and tests are passing
  • Update documentation
  • Updated CHANGELOG
  • Checked for i18n impacts
  • Checked for a11y impacts

📷 Screenshots

With windowing:

windowed

Without windowing:

unwindowed

@@ -42,6 +42,9 @@ const observeMock = function(cb, config) {
const globalAny = global
globalAny.IntersectionObserver = observeMock

// js-dom doesn't do scrollIntoView
Element.prototype.scrollIntoView = jest.fn()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got an error on this guy in one of the new tests. jsdom/jsdom#1695

if (contentContainer) {
setListClientRectOnce(contentContainer)
setListScrollPosition &&
setListScrollPosition(contentContainer.scrollTop)
}
}
}, 50)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking any performance boost I can get.

contentContainer.addEventListener('scroll', scrollListener)
scrollListener()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now need the height and scroll position right away on initial render.


return () => {
contentContainer &&
contentContainer.removeEventListener('scroll', scrollListener)
setListScrollPosition && setListScrollPosition(0)
setListClientRect && setListClientRect(undefined)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these values are going into the context, we have to clear them when the list unmounts, because the provider is still mounted and otherwise persists those previous values.

if (optionsRefCurrent) {
const windowedOptions =
windowedOptionsPropRef && windowedOptionsPropRef.current
if (optionsRefCurrent && !windowedOptions) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook is for letting each option "register" itself to the context when it mounts to support keyboard navigation, but since any option outside the scroll window will not be mounted, we disable this behavior here and add it a different way in useWindowedOptions.

@@ -80,7 +81,7 @@ export function useKeyDown() {
}
}

return function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
return throttle(function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helps performance when the user clicks and holds an arrow key.

@@ -62,10 +67,11 @@ export function useOptionEvents<
}
}

function handleMouseEnter() {
const handleMouseEnter = throttle(() => {
if (isScrollingRef && isScrollingRef.current) return
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solves an existing issue – without this, if you keyboard navigate down to options below the scroll window, causing a small auto-scroll, and your mouse is resting somewhere on the list, a mouseenter will be triggered where your mouse is.
(isScrollingRef set/unset below in useOptionsScroll before scrollIntoView is called.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah that's awesome!

key="select-multi"
/>,
],
])('custom label text (%s)', (_, jsx) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Git diff is being funny here. This was an existing test, but the 2 above are now.

const renderMultiOption = (
option: SelectOptionObject,
index: number,
scrollIntoView?: boolean
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enables scrolling to the bottom of the list if the user arrows up from the first option or to the top of the list if the user arrows down from the last option.

return false
}
return true
}, [options, propsWindowedOptions])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When to window the options – option length is 100 or more, by default, with prop override.

scrollToLast,
} = useWindowedOptions(windowedOptions, options, isMulti)

const optionsToRender = options && options.slice(start, end + 1)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will include the entire list if windowedOptions is false (see getWindowedListBoundaries.ts).

@@ -192,25 +252,41 @@ export function SelectOptions({

return (
<>
{options && options.length > 0
{options && scrollToFirst
? renderToUse(options[0] as SelectOptionObject, 0, true)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user arrows down from the last option.

options[options.length - 1] as SelectOptionObject,
0,
true
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user arrows up from the first option.

'The `windowedOptions` prop does not support grouped options.'
)
}
return false
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not able to window with grouped options at this time.

@mdodgelooker mdodgelooker requested review from dbchristopher and a user May 3, 2020 21:44
Copy link
Contributor

@dbchristopher dbchristopher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really cool meredith! That's a super hard behavior to get working right. The only issue I could find was when it should jump down from the top to the bottom of the list when pressing up

* list of options in order for keyboard navigation to work properly
* @default false
*/
windowedOptions?: boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if lazyRenderOptions better describe the behavior? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think convention calls for either "virtualize" or "window" https://reactjs.org/docs/optimizing-performance.html#virtualize-long-lists

I went for "window" because it's shorter but maybe "virtualize" is a more specific word... thoughts? I wonder if @lukelooker has an opinion... 😁

} = useContext(context)
/* scroll menu list to specified element on mount */
const [newTriggerElement, callbackRef] = useCallbackRef()
useEffect(() => {
if (scrollIntoView) {
if (newTriggerElement) {
// if (isScrollingRef) isScrollingRef.current = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 95 to 98
if (isScrollingRef) isScrollingRef.current = true
window.requestAnimationFrame(() => {
if (isScrollingRef) isScrollingRef.current = false
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice. 👍

// Resolves "act" warning
await wait()

// Close popover to silence act() warning
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥳

Comment on lines 298 to 300
expect(queryByText('0')).toBeInTheDocument()
expect(queryByText('99')).not.toBeInTheDocument()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem to validate that 99 options all render. should you also add that queryByText('99') is in the document?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I'm not sure that I can see any difference between the component rendered here and in the previous test. Is the windowedOptions prop toggled somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh whoops, good catch – bad copy/pasting on my part. 🤪Now it's checking that "98" (the 99th option) is rendered.

The default behavior is to turn on windowing at 100, which I've read is where performance starts to go downhill. Then the windowedOptions prop can be used to override the default (either on or off, depending on the length of options).

Comment on lines +99 to +100
// If the user keyboard navigates "down" from the last option or "up" from the first option
// we need to render the top or bottom of the list (which are outside our "window") and scroll there
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to be working as expected. I'm continually pressing up arrow in this screencast:
up-nav

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, looking into it now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed now. I had moved the logic that automatically turns on windowing at 100 options to the wrong spot. Now it's a hook under Select/utils so it can be used by both Select and SelectMulti.

@mdodgelooker
Copy link
Contributor Author

Ready for another round of review @dbchristopher 🙂

Copy link
Contributor

@dbchristopher dbchristopher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really great @mdodgelooker! So awesome this can support such insane use cases now. Everything works really well. Ship it!

@mdodgelooker mdodgelooker merged commit b3ab439 into master May 5, 2020
@mdodgelooker mdodgelooker deleted the mdodge/select-performance branch May 5, 2020 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants