-
Notifications
You must be signed in to change notification settings - Fork 319
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
Optimize Venues search using React Concurrent Mode APIs #3040
base: eliang/concurrent-mode
Are you sure you want to change the base?
Changes from all commits
439bfcb
bb8b6af
a6d0cab
80102e5
4423a00
ce167bc
391e96b
e596bc5
ca2a38b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
// Source: https://reactjs.org/docs/concurrent-mode-patterns.html#delaying-a-pending-indicator | ||
.deferred { | ||
visibility: hidden; | ||
animation: 0s linear 0.2s forwards makeVisible; | ||
|
||
@keyframes makeVisible { | ||
to { | ||
visibility: visible; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
.loadingOverlay { | ||
position: absolute; | ||
top: 0; | ||
right: 0; | ||
bottom: 0; | ||
left: 0; | ||
z-index: 100; | ||
background: var(--body-bg-70); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import classnames from 'classnames'; | ||
import type { FC } from 'react'; | ||
import styles from './LoadingOverlay.scss'; | ||
|
||
type Props = { | ||
deferred?: boolean; | ||
}; | ||
|
||
const LoadingOverlay: FC<Props> = ({ children, deferred }) => ( | ||
<div | ||
className={classnames(styles.loadingOverlay, { | ||
deferred, // Use global deferred class | ||
})} | ||
> | ||
{children} | ||
</div> | ||
); | ||
|
||
export default LoadingOverlay; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,19 +15,21 @@ import styles from './SearchBox.scss'; | |
|
||
export type Props = { | ||
className?: string; | ||
throttle: number; | ||
isLoading: boolean; | ||
throttle?: number; | ||
isLoading?: boolean; | ||
value: string | null; | ||
placeholder?: string; | ||
/** Called when the search box value changes */ | ||
onChange: (value: string) => void; | ||
onSearch: () => void; | ||
/** Called when a search should be triggered, potentially debounced by `throttle` milliseconds. */ | ||
onSearch?: () => void; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be useful adding a small comment on the difference between this and onChange |
||
onBlur?: () => void; | ||
}; | ||
|
||
const SearchBox: FC<Props> = ({ | ||
className, | ||
throttle, | ||
isLoading, | ||
isLoading = false, | ||
value, | ||
placeholder, | ||
onChange, | ||
|
@@ -55,7 +57,7 @@ const SearchBox: FC<Props> = ({ | |
debounce( | ||
() => { | ||
isDirty.current = false; | ||
onSearch(); | ||
onSearch?.(); | ||
}, | ||
throttle, | ||
{ leading: false }, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
import * as React from 'react'; | ||
import type { FC } from 'react'; | ||
import { | ||
Hits, | ||
HitsStats, | ||
|
@@ -12,9 +12,10 @@ import { | |
} from 'searchkit'; | ||
import classnames from 'classnames'; | ||
|
||
import { ElasticSearchResult } from 'types/vendor/elastic-search'; | ||
import { ModuleInformation } from 'types/modules'; | ||
import type { ElasticSearchResult } from 'types/vendor/elastic-search'; | ||
import type { ModuleInformation } from 'types/modules'; | ||
|
||
import LoadingOverlay from 'views/components/LoadingOverlay'; | ||
import ModuleFinderSidebar from 'views/modules/ModuleFinderSidebar'; | ||
import ModuleSearchBox from 'views/modules/ModuleSearchBox'; | ||
import ModuleFinderNoHits from 'views/errors/ModuleFinderNoHits'; | ||
|
@@ -38,7 +39,7 @@ const searchkit = new SearchkitManager(esHostUrl, { | |
|
||
const pageHead = <Title>Modules</Title>; | ||
|
||
const ModuleInformationListComponent: React.FC<HitsListProps> = ({ hits }) => ( | ||
const ModuleInformationListComponent: FC<HitsListProps> = ({ hits }) => ( | ||
<ul className={styles.modulesList}> | ||
{hits.map((hit) => { | ||
const result = hit as ElasticSearchResult<ModuleInformation>; | ||
|
@@ -55,7 +56,7 @@ const ModuleInformationListComponent: React.FC<HitsListProps> = ({ hits }) => ( | |
</ul> | ||
); | ||
|
||
const ModuleFinderContainer: React.FC = () => ( | ||
const ModuleFinderContainer: FC = () => ( | ||
<div className={classnames(styles.modulesPageContainer, 'page-container')}> | ||
{pageHead} | ||
<SearchkitProvider searchkit={searchkit}> | ||
|
@@ -73,7 +74,7 @@ const ModuleFinderContainer: React.FC = () => ( | |
/> | ||
|
||
<LoadingComponent> | ||
<div className={styles.loadingOverlay} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should remove the unused duplicate class here from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, it was already removed |
||
<LoadingOverlay /> | ||
</LoadingComponent> | ||
|
||
<InitialLoader component={LoadingComponent} /> | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,19 +1,27 @@ | ||||||||||||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'; | ||||||||||||||
import { | ||||||||||||||
FC, | ||||||||||||||
unstable_useDeferredValue as useDeferredValue, | ||||||||||||||
useCallback, | ||||||||||||||
useEffect, | ||||||||||||||
useMemo, | ||||||||||||||
useState, | ||||||||||||||
} from 'react'; | ||||||||||||||
import { useHistory, useLocation, useParams } from 'react-router-dom'; | ||||||||||||||
import Loadable, { LoadingComponentProps } from 'react-loadable'; | ||||||||||||||
// eslint-disable-next-line import/no-extraneous-dependencies | ||||||||||||||
import { Location, locationsAreEqual } from 'history'; | ||||||||||||||
import classnames from 'classnames'; | ||||||||||||||
import axios from 'axios'; | ||||||||||||||
import qs from 'query-string'; | ||||||||||||||
import { isEqual, mapValues, noop, pick, size } from 'lodash'; | ||||||||||||||
import { isEqual, mapValues, pick, size } from 'lodash'; | ||||||||||||||
|
||||||||||||||
import type { TimePeriod, Venue, VenueDetailList, VenueSearchOptions } from 'types/venues'; | ||||||||||||||
import type { Subtract } from 'types/utils'; | ||||||||||||||
|
||||||||||||||
import deferComponentRender from 'views/hocs/deferComponentRender'; | ||||||||||||||
import ApiError from 'views/errors/ApiError'; | ||||||||||||||
import Warning from 'views/errors/Warning'; | ||||||||||||||
import LoadingOverlay from 'views/components/LoadingOverlay'; | ||||||||||||||
import LoadingSpinner from 'views/components/LoadingSpinner'; | ||||||||||||||
import SearchBox from 'views/components/SearchBox'; | ||||||||||||||
import { Clock } from 'react-feather'; | ||||||||||||||
|
@@ -46,18 +54,13 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
const location = useLocation(); | ||||||||||||||
const matchParams = useParams<Params>(); | ||||||||||||||
|
||||||||||||||
// Search state | ||||||||||||||
const [ | ||||||||||||||
/** Value of the controlled search box; updated real-time */ | ||||||||||||||
searchQuery, | ||||||||||||||
setSearchQuery, | ||||||||||||||
] = useState<string>(() => qs.parse(location.search).q || ''); | ||||||||||||||
/** Actual string to search with; deferred update */ | ||||||||||||||
const deferredSearchQuery = searchQuery; // TODO: Redundant now. Use React.useDeferredValue after we adopt concurrent mode | ||||||||||||||
const [searchQuery, setSearchQuery] = useState<string>(() => qs.parse(location.search).q || ''); | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /nit
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That doesn't work because
Suggested change
|
||||||||||||||
|
||||||||||||||
const [isAvailabilityEnabled, setIsAvailabilityEnabled] = useState(() => { | ||||||||||||||
const params = qs.parse(location.search); | ||||||||||||||
return !!(params.time && params.day && params.duration); | ||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
const [searchOptions, setSearchOptions] = useState(() => { | ||||||||||||||
const params = qs.parse(location.search); | ||||||||||||||
// Extract searchOptions from the query string if they are present | ||||||||||||||
|
@@ -69,6 +72,14 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
}); | ||||||||||||||
const [pristineSearchOptions, setPristineSearchOptions] = useState(() => !isAvailabilityEnabled); | ||||||||||||||
|
||||||||||||||
const deferredSearchQuery = useDeferredValue(searchQuery); | ||||||||||||||
const deferredIsAvailabilityEnabled = useDeferredValue(isAvailabilityEnabled); | ||||||||||||||
const deferredSearchOptions = useDeferredValue(searchOptions); | ||||||||||||||
const isPending = | ||||||||||||||
searchQuery !== deferredSearchQuery || | ||||||||||||||
isAvailabilityEnabled !== deferredIsAvailabilityEnabled || | ||||||||||||||
searchOptions !== deferredSearchOptions; | ||||||||||||||
|
||||||||||||||
// TODO: Check if this actually does anything useful | ||||||||||||||
useEffect(() => { | ||||||||||||||
VenueLocation.preload(); | ||||||||||||||
|
@@ -78,9 +89,9 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
setIsAvailabilityEnabled(!isAvailabilityEnabled); | ||||||||||||||
if (pristineSearchOptions && !isAvailabilityEnabled) { | ||||||||||||||
// Only reset search options if the user has never changed it, and if the | ||||||||||||||
// search box is being opened. By resetting the option when the box is opened, | ||||||||||||||
// the time when the box is opened will be used, instead of the time when the | ||||||||||||||
// page is loaded | ||||||||||||||
// search box is being opened. By resetting the option when the box is | ||||||||||||||
// opened, the time when the box is opened will be used, instead of the | ||||||||||||||
// time when the page is loaded. | ||||||||||||||
setSearchOptions(defaultSearchOptions()); | ||||||||||||||
} | ||||||||||||||
}, [isAvailabilityEnabled, pristineSearchOptions]); | ||||||||||||||
|
@@ -96,14 +107,21 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
); | ||||||||||||||
|
||||||||||||||
const highlightPeriod = useMemo<TimePeriod | undefined>(() => { | ||||||||||||||
if (!isAvailabilityEnabled) return undefined; | ||||||||||||||
if (!deferredIsAvailabilityEnabled) return undefined; | ||||||||||||||
|
||||||||||||||
return { | ||||||||||||||
day: searchOptions.day, | ||||||||||||||
startTime: convertIndexToTime(searchOptions.time * 2), | ||||||||||||||
endTime: convertIndexToTime(2 * (searchOptions.time + searchOptions.duration)), | ||||||||||||||
day: deferredSearchOptions.day, | ||||||||||||||
startTime: convertIndexToTime(deferredSearchOptions.time * 2), | ||||||||||||||
endTime: convertIndexToTime( | ||||||||||||||
2 * (deferredSearchOptions.time + deferredSearchOptions.duration), | ||||||||||||||
), | ||||||||||||||
}; | ||||||||||||||
}, [isAvailabilityEnabled, searchOptions.day, searchOptions.duration, searchOptions.time]); | ||||||||||||||
}, [ | ||||||||||||||
deferredIsAvailabilityEnabled, | ||||||||||||||
deferredSearchOptions.day, | ||||||||||||||
deferredSearchOptions.duration, | ||||||||||||||
deferredSearchOptions.time, | ||||||||||||||
]); | ||||||||||||||
|
||||||||||||||
const selectedVenue = useMemo<Venue | undefined>( | ||||||||||||||
() => (matchParams.venue ? decodeURIComponent(matchParams.venue) : undefined), | ||||||||||||||
|
@@ -114,7 +132,7 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
useEffect(() => { | ||||||||||||||
let query: Partial<Params> = {}; | ||||||||||||||
if (deferredSearchQuery) query.q = deferredSearchQuery; | ||||||||||||||
if (isAvailabilityEnabled) query = { ...query, ...searchOptions }; | ||||||||||||||
if (deferredIsAvailabilityEnabled) query = { ...query, ...deferredSearchOptions }; | ||||||||||||||
const search = qs.stringify(query); | ||||||||||||||
|
||||||||||||||
const pathname = venuePage(selectedVenue); | ||||||||||||||
|
@@ -132,17 +150,19 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
} | ||||||||||||||
}, [ | ||||||||||||||
debouncedHistory, | ||||||||||||||
deferredIsAvailabilityEnabled, | ||||||||||||||
deferredSearchOptions, | ||||||||||||||
deferredSearchQuery, | ||||||||||||||
history, | ||||||||||||||
isAvailabilityEnabled, | ||||||||||||||
searchOptions, | ||||||||||||||
selectedVenue, | ||||||||||||||
]); | ||||||||||||||
|
||||||||||||||
const matchedVenues = useMemo(() => { | ||||||||||||||
const matched = searchVenue(venues, deferredSearchQuery); | ||||||||||||||
return isAvailabilityEnabled ? filterAvailability(matched, searchOptions) : matched; | ||||||||||||||
}, [isAvailabilityEnabled, searchOptions, deferredSearchQuery, venues]); | ||||||||||||||
return deferredIsAvailabilityEnabled | ||||||||||||||
? filterAvailability(matched, deferredSearchOptions) | ||||||||||||||
: matched; | ||||||||||||||
}, [deferredIsAvailabilityEnabled, deferredSearchOptions, deferredSearchQuery, venues]); | ||||||||||||||
const matchedVenueNames = useMemo(() => matchedVenues.map(([venue]) => venue), [matchedVenues]); | ||||||||||||||
|
||||||||||||||
function renderSearch() { | ||||||||||||||
|
@@ -152,18 +172,14 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
|
||||||||||||||
<SearchBox | ||||||||||||||
className={styles.searchBox} | ||||||||||||||
throttle={0} | ||||||||||||||
isLoading={false} | ||||||||||||||
value={searchQuery} | ||||||||||||||
placeholder="e.g. LT27" | ||||||||||||||
onChange={setSearchQuery} | ||||||||||||||
onSearch={noop} | ||||||||||||||
/> | ||||||||||||||
|
||||||||||||||
<button | ||||||||||||||
className={classnames( | ||||||||||||||
'btn btn-block btn-svg', | ||||||||||||||
styles.availabilityToggle, | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This style didn't exist |
||||||||||||||
isAvailabilityEnabled ? 'btn-primary' : 'btn-outline-primary', | ||||||||||||||
)} | ||||||||||||||
onClick={onFindFreeRoomsClicked} | ||||||||||||||
|
@@ -213,11 +229,15 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => { | |||||||||||||
<div className={styles.venuesList}> | ||||||||||||||
{renderSearch()} | ||||||||||||||
|
||||||||||||||
{size(matchedVenues) === 0 ? ( | ||||||||||||||
renderNoResult() | ||||||||||||||
) : ( | ||||||||||||||
<div className="position-relative"> | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think without specifying height this will not take up the whole height of the venues list right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||
{isPending && ( | ||||||||||||||
<LoadingOverlay deferred> | ||||||||||||||
<LoadingSpinner /> | ||||||||||||||
</LoadingOverlay> | ||||||||||||||
)} | ||||||||||||||
{!isPending && size(matchedVenues) === 0 && renderNoResult()} | ||||||||||||||
<VenueList venues={matchedVenueNames} selectedVenue={selectedVenue} /> | ||||||||||||||
)} | ||||||||||||||
</div> | ||||||||||||||
</div> | ||||||||||||||
|
||||||||||||||
<VenueDetailsPane | ||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could in theory make this configurable from the component side using CSS variables or even inline styles, but probably YAGNI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, probably not a good idea to overengineer this right now hahaa