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

Optimize Venues search using React Concurrent Mode APIs #3040

Open
wants to merge 9 commits into
base: eliang/concurrent-mode
Choose a base branch
from
1 change: 1 addition & 0 deletions website/src/styles/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@import 'utils/workload';
@import 'utils/themes';
@import 'utils/scrollable';
@import 'utils/deferred';

// Layout
@import 'layout/site';
Expand Down
11 changes: 11 additions & 0 deletions website/src/styles/utils/deferred.scss
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;
Copy link
Member

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

Copy link
Member Author

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


@keyframes makeVisible {
to {
visibility: visible;
}
}
}
9 changes: 9 additions & 0 deletions website/src/views/components/LoadingOverlay.scss
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);
}
19 changes: 19 additions & 0 deletions website/src/views/components/LoadingOverlay.tsx
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;
12 changes: 7 additions & 5 deletions website/src/views/components/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -55,7 +57,7 @@ const SearchBox: FC<Props> = ({
debounce(
() => {
isDirty.current = false;
onSearch();
onSearch?.();
},
throttle,
{ leading: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,3 @@ div.modulesPageContainer {
text-align: right;
color: var(--gray);
}

.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
@@ -1,4 +1,4 @@
import * as React from 'react';
import type { FC } from 'react';
import {
Hits,
HitsStats,
Expand All @@ -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';
Expand All @@ -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>;
Expand All @@ -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}>
Expand All @@ -73,7 +74,7 @@ const ModuleFinderContainer: React.FC = () => (
/>

<LoadingComponent>
<div className={styles.loadingOverlay} />
Copy link
Member

Choose a reason for hiding this comment

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

Should remove the unused duplicate class here from ModuleFinderContainer.scss

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, it was already removed

<LoadingOverlay />
</LoadingComponent>

<InitialLoader component={LoadingComponent} />
Expand Down
2 changes: 1 addition & 1 deletion website/src/views/venues/AvailabilitySearch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defaultSearchOptions } from 'views/venues/AvailabilitySearch';

describe('defaultSearchOptions', () => {
describe(defaultSearchOptions, () => {
test('should the nearest slots during school hours', () => {
// Monday
expect(defaultSearchOptions(new Date('2018-01-15T12:30:00'))).toMatchObject({
Expand Down
82 changes: 51 additions & 31 deletions website/src/views/venues/VenuesContainer.tsx
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';
Expand Down Expand Up @@ -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 || '');
Copy link
Member

Choose a reason for hiding this comment

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

/nit

Suggested change
const [searchQuery, setSearchQuery] = useState<string>(() => qs.parse(location.search).q || '');
const [searchQuery, setSearchQuery] = useState(() => qs.parse(location.search).q || '');

Copy link
Member Author

Choose a reason for hiding this comment

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

That doesn't work because qs.parse returns any. We could do the below type assertion instead, but the current code seems good enough

Suggested change
const [searchQuery, setSearchQuery] = useState<string>(() => qs.parse(location.search).q || '');
const [searchQuery, setSearchQuery] = useState(
() => (qs.parse(location.search) as Params).q || '',
);


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
Expand All @@ -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();
Expand All @@ -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]);
Expand All @@ -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),
Expand All @@ -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);
Expand All @@ -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() {
Expand All @@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The 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}
Expand Down Expand Up @@ -213,11 +229,15 @@ export const VenuesContainerComponent: FC<Props> = ({ venues }) => {
<div className={styles.venuesList}>
{renderSearch()}

{size(matchedVenues) === 0 ? (
renderNoResult()
) : (
<div className="position-relative">
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it does since it wraps the venue list component

image

{isPending && (
<LoadingOverlay deferred>
<LoadingSpinner />
</LoadingOverlay>
)}
{!isPending && size(matchedVenues) === 0 && renderNoResult()}
<VenueList venues={matchedVenueNames} selectedVenue={selectedVenue} />
)}
</div>
</div>

<VenueDetailsPane
Expand Down