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

Overhaul Venues components #3038

Open
wants to merge 32 commits into
base: master
Choose a base branch
from

Conversation

taneliang
Copy link
Member

@taneliang taneliang commented Dec 8, 2020

Context

This is a rather large PR that makes various changes to the components on the Venues page.

Details

The biggest (and probably only?) user-facing change is that search box optimizations have been removed. This will be restored in #3040 with optimizations that use concurrent mode. As a result, we'll want to land this stack of 3 PRs together to prevent performance regressions in prod.

This PR does optimize a few components. Specifically, it reduces unnecessary renders of expensive components like VenueList and even the whole VenuesContainer. More details are in the commit messages.

SearchBox changes aren't strictly related to the rest of the changes, but they were necessary to allow #3040 to experiment with concurrent mode APIs in the search box. I didn't use any of those experiments though.

Similar to #3007 and #3009, this PR also works towards:

Completes #3009 by removing the last use of withRouter.

Cost of `memo` is probably higher than perf savings for `VenueDetails`.
All expensive variables in `VenueDetails` are memoized now, and
`Timetable` is a PureComponent, so rendering `VenueDetails` should be
cheap.

Also, `VenuesContainer` (the only component that uses `VenueDetails`)
doesn't take advantage of memoization anyway as it passes a new
`highlightPeriod` object on every render.
Though, this doesn't currently have any effect as `VenuesContainer`
passes in a new `venues` prop on every render.
To support non-DOM environments (e.g. tests that use MemoryRouter)
@codecov
Copy link

codecov bot commented Dec 8, 2020

Codecov Report

Merging #3038 (381ced3) into master (8e7d591) will increase coverage by 1.61%.
The diff coverage is 81.87%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3038      +/-   ##
==========================================
+ Coverage   55.58%   57.20%   +1.61%     
==========================================
  Files         255      257       +2     
  Lines        5311     5283      -28     
  Branches     1218     1205      -13     
==========================================
+ Hits         2952     3022      +70     
+ Misses       2359     2261      -98     
Impacted Files Coverage Δ
...ebsite/src/views/components/SearchkitSearchBox.tsx 0.00% <ø> (ø)
website/src/views/venues/VenueLocation/index.tsx 100.00% <ø> (ø)
...e/src/views/venues/VenueLocation/VenueLocation.tsx 7.14% <8.33%> (+7.14%) ⬆️
...e/src/views/venues/VenueLocation/FeedbackModal.tsx 6.25% <9.09%> (+6.25%) ⬆️
...rc/views/venues/VenueLocation/AddLocationModal.tsx 50.00% <50.00%> (ø)
website/src/views/venues/VenuesContainer.tsx 77.92% <90.38%> (+5.50%) ⬆️
website/src/views/venues/VenueDetailsPane.tsx 92.85% <92.85%> (ø)
website/src/views/components/SearchBox.tsx 100.00% <100.00%> (+56.60%) ⬆️
website/src/views/venues/AvailabilitySearch.tsx 100.00% <100.00%> (+63.15%) ⬆️
website/src/views/venues/VenueDetails.tsx 90.00% <100.00%> (+80.90%) ⬆️
... and 10 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8e7d591...381ced3. Read the comment docs.

@nusmods-deploy-bot
Copy link

nusmods-deploy-bot bot commented Dec 8, 2020

Comment on lines 40 to 42
// This is a ref instead of state as it is only used within event handlers and
// we don't want to trigger unnecessary renders when it's changed.
const hasChanges = useRef(false);
Copy link
Member Author

Choose a reason for hiding this comment

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

Let me know if there are objections to this 😆

Copy link
Member

Choose a reason for hiding this comment

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

Usually this is called dirty

venues: VenueDetailList;
};

const VenueDetailsPaneComponent: FC<Props> = ({
Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure if this file name (VenueDetailsPane) makes sense, as there's a VenueDetails component as well. I'm calling it a pane as it's basically the right pane of the Venues page's split view. Suggestions welcome.

Btw, there's no LeftPane or VenueListPane because the left pane depends very heavily on VenuesContainer state, which is also used by VenueDetailsPane. We could consider using a context but I don't think it's worth it.

onRequestClose: () => void;
};

const AddLocationModal: FC<Props> = ({ venue, isOpen, onRequestClose }) => (
Copy link
Member Author

Choose a reason for hiding this comment

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

Moved this into its own component (and file) as it seemed unclean that FeedbackModal was its own component and file but the add location modal was inline.

Copy link
Member

Choose a reason for hiding this comment

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

Mostly because it's just a thin wrapper around ImproveVenueForm so the code is very trivial, but making it SLAP is not a bad idea

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
Copy link
Member Author

Choose a reason for hiding this comment

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

Done in #3040

@@ -384,8 +248,8 @@ const AsyncVenuesContainer = Loadable.Map<Subtract<Props, LoadedProps>, { venues

return null;
},
render(loaded, props) {
return <RoutedVenuesContainer venues={sortVenues(loaded.venues.data)} {...props} />;
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 caused so many unnecessary renders 😅

Copy link
Member

Choose a reason for hiding this comment

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

Surprising - is this an issue with React Loadable or are we just not using it 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 we were just not using it right. We were passing a new venues prop to RoutedVenuesContainer on every render, so any memoized value/component that used venues would always be recomputed.

@taneliang taneliang marked this pull request as ready for review December 8, 2020 15:11
@taneliang taneliang requested a review from a team December 8, 2020 15:11
this.setState({ hasChanges: true });
if (this.props.useInstantSearch) this.debouncedSearch();
const debouncedSearch = useMemo(() => {
function search() {
Copy link
Member

Choose a reason for hiding this comment

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

It seems this function can just be inlined

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 did try that, but I felt that the resulting debounce(() => {...}, throttle, { leading: false }) was harder to understand, but maybe it's pretty obvious since this var is called debouncedSearch. What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I think it's clear enough, esp. since it's obviously a wrapper around onSearch. It might actually be clearer if we use useCallback -

const debounceedSearch = useCallback(debounce(() => {
  hasChanges.current = false;
  onSearch();
}, throttle, { leading: false }, [onSearch, throttle]);

I'm actually not sure if the debounce behavior will work correctly if throttle or onSearch changes and cause a recomputation lol, but oh well

Copy link
Member Author

Choose a reason for hiding this comment

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

It might actually be clearer if we use useCallback

Oh yeah, good catch.

I'm actually not sure if the debounce behavior will work correctly if throttle or onSearch changes and cause a recomputation

Yeah this may be a bug. I'll add a FIXME, but currently the throttle and onSearch props are always passed stable values.

Copy link
Member Author

Choose a reason for hiding this comment

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

It might actually be clearer if we use useCallback

Oh yeah, good catch.

Actually no, this won't work well as debounce returns something that's more than a function and some lint rules fail. I think useMemo is more appropriate.

image

Copy link
Member

Choose a reason for hiding this comment

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

Hmmm. So the lint rule is not about types (notice TS is perfectly happy here), but rather because the exhaustive-deps rule depends on the parameter being an inline function so that it can statically analyze its content for deps. I guess we have to think about whether the clarity of useCallback is worth trading off exhaustive-deps no longer working here. No strong opinions either way.

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 that is true. I guess my bigger objection is that we're trying to memoize something that's more than a function. Since useCallback seems to be intended to memoize functions (from the docs: useCallback(**fn**, deps)), I think it may be a little unsafe to assume that useCallback will return the exact callback that's passed to it (even though it currently does).

Copy link
Member

Choose a reason for hiding this comment

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

So this is getting philosophical, but functions are objects in JS. Functions have both native properties, and can have additional properties attached to them (React in fact uses this itself with displayName and defaultProps on functional components). Any function that wants to correctly proxy function should take this into account.

On a more practical level, the reason useCallback exists at all, other than being sugar, is the assumption that declaring a function is cheap, so it is okay to write useCallback(() => { /* some long function declaration */ }) since just declaring the function is fast. IMO _.debounce is trivial enough that this is fine. But having the linter work is probably more valuable. So 🤷

Copy link
Member Author

Choose a reason for hiding this comment

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

Good points. I was concerned that useCallback would potentially do something weird in the future like wrapping the callback in an IIFE, but your argument makes sense.

I guess we'll just leave this as is so that we don't have to disable the lint rule

<Search className={classnames(styles.leftAccessory, styles.searchIcon)} />
)}
{value && (
<X className={styles.removeInput} onClick={clearInput} pointerEvents="bounding-box" />
Copy link
Member

Choose a reason for hiding this comment

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

This should have an aria-label and role - this will make the tests easier to read and improve accessibility

Comment on lines 45 to 49
return [
(event) => onUpdateInner(event, 'day'),
(event) => onUpdateInner(event, 'time'),
(event) => onUpdateInner(event, 'duration'),
] as ChangeEventHandler<HTMLSelectElement>[];
Copy link
Member

Choose a reason for hiding this comment

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

Casting isn't safe. Either do the following or use a generic on useMemo to correctly type the returned value

Suggested change
return [
(event) => onUpdateInner(event, 'day'),
(event) => onUpdateInner(event, 'time'),
(event) => onUpdateInner(event, 'duration'),
] as ChangeEventHandler<HTMLSelectElement>[];
const handlers: ChangeEventHandler<HTMLSelectElement>[] = [
(event) => onUpdateInner(event, 'day'),
(event) => onUpdateInner(event, 'time'),
(event) => onUpdateInner(event, 'duration'),
];
return handlers;

Copy link
Member

Choose a reason for hiding this comment

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

The useMemo is probably not very useful anyway - the only of the props that should change often is searchOptions, which is also a dep here. onUpdate can be changed to the Dispatch form to remove that as a dep but this onChange is passed into a native select anyway so I don't think the performance difference is huge

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 onChange is passed into a native select anyway so I don't think the performance difference is huge

True. And yeah it's definitely not very clean to have 2 props change together in 2 different components. I think it's fine to leave it as is though (after fixing that typecast). We can improve this some other time.

website/src/views/venues/VenueDetailsPane.tsx Outdated Show resolved Hide resolved
onRequestClose: () => void;
};

const AddLocationModal: FC<Props> = ({ venue, isOpen, onRequestClose }) => (
Copy link
Member

Choose a reason for hiding this comment

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

Mostly because it's just a thin wrapper around ImproveVenueForm so the code is very trivial, but making it SLAP is not a bad idea

const rendered = renderWithRouterMatch(
<>
<VenuesContainerComponent venues={venues} />
<div id={modalElementId} />
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure if this is correct? Why do we need this?

If we do want this the following might be a bit more ergonomic?

function setupReactModal() {
  return {
    rootElement: <div id="test-react-modal-root" />,
    init: () => ReactModal.setAppElement('#test-react-modal-root'),
  }
}

// ... 
const testReactModal = setupReactModal();

render(
  <>
    {...}
    {testReactModal.rootElement}
  </>
);

testReactModal.init();

Copy link
Member Author

Choose a reason for hiding this comment

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

Why do we need this?

ReactModal just needs an element to render into, otherwise it'll throw an error (iirc). This was the best way I could come up with to fix that. Do you have any other ideas? Your suggestion definitely looks cleaner, so if we can't think of any other way we can just do that

Copy link
Member

Choose a reason for hiding this comment

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

Hmmm well I'm mainly worried because the API wants the root element, but I don't know how that works with React Testing Library (their docs don't mention anything about it). If we just need an element that's in the DOM you could just do this in the framework setup code

cost modalRoot = document.createElement('div');
document.body.appendChild(modalRoot);
ReactModal.setRootElement(modalRoot);

Copy link
Member Author

Choose a reason for hiding this comment

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

the API wants the root element

Ah right. In that case maybe we can just use ReactModal.setAppElement(rendered.view.container as HTMLElement);, then we don't need any setup code

expect(box).not.toHaveFocus();
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockOnSearch).not.toHaveBeenCalled();
Copy link
Member

Choose a reason for hiding this comment

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

onSearch is debounced, so testing by counting calls might not be accurate. Not sure if there is a better way though, maybe add some mock timer waits to force for debounce to clear?

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we could do something like await expect(waitFor(() => expect(mockOnSearch).toHaveBeenCalled())).rejects.toThrowError()? Hahaha

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 mock debounce instead to immediately invoke the function.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not a bad idea, but this will couple our test to an implementation detail of SearchBox :(

Tbh I think this debouncing/throttling logic should be moved into SearchkitSearchBox instead since it's now only relevant for module search. I think we should just file an issue for this and defer this discussion to another time?

Copy link
Member

Choose a reason for hiding this comment

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

I don't buy into the implementation detail test article as much. It is useful to keep in mind, but patterns like the page object pattern (why I asked if we should extract some of the queries into constants or functions) are more useful IMO to serve the goal of improving test robustness and reducing breakage when implementation changes.

Here specifically we want to test implementation details, because testing exactly what the user sees would be kind of ridiculous - the goal of the debounce is to improve rendering performance, and to test that in a concrete manner would probably involve, what, rendering this with some arbitrary CPU throttling to prove it is fast enough?

Copy link
Member Author

@taneliang taneliang Dec 20, 2020

Choose a reason for hiding this comment

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

the goal of the debounce is to improve rendering performance

I don't think this is true anymore. The venues page previously used this for performance, but now that the venues search uses useDeferredValue for perf (at least after #3040), this is only used to throttle our module search so that we don't make too many requests. Venues search no longer uses onSearch.

That's why this throttling/debouncing stuff should be moved into SearchkitSearchBox imo, and we can lazily sidestep this discussion entirely since SearchkitSearchBox doesn't have tests 😆

expect(backButton).toBeInTheDocument();

userEvent.click(backButton);
expect(backButton).not.toBeInTheDocument();
Copy link
Member

Choose a reason for hiding this comment

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

Maybe check URL too (or instead? Checking the button is gone is a bit weird)

@@ -384,8 +248,8 @@ const AsyncVenuesContainer = Loadable.Map<Subtract<Props, LoadedProps>, { venues

return null;
},
render(loaded, props) {
return <RoutedVenuesContainer venues={sortVenues(loaded.venues.data)} {...props} />;
Copy link
Member

Choose a reason for hiding this comment

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

Surprising - is this an issue with React Loadable or are we just not using it right?

const clearInput = useCallback(() => {
onChange('');
hasChanges.current = true;
debouncedSearch();
Copy link
Member

Choose a reason for hiding this comment

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

This should flush I think, feels like an oversight not having it

Copy link
Member Author

Choose a reason for hiding this comment

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

So turns out if we add a flush we break SearchkitSearchBox because Searchkit's search is triggered before SearchkitSearchBox's state input is updated.

Copy link
Member

Choose a reason for hiding this comment

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

Ooof. Okay worth a comment

@taneliang
Copy link
Member Author

Thanks for the detailed review @ZhangYiJiang! I'll likely address the comments much later in the week as I have in-camp training until Thursday.

Copy link
Member

@li-kai li-kai left a comment

Choose a reason for hiding this comment

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

I'll give the go ahead. I don't see any major issues with this code change. ☃️

expect(box).not.toHaveFocus();
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockOnSearch).not.toHaveBeenCalled();
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 mock debounce instead to immediately invoke the function.

Comment on lines +66 to +68
const [previous] = matchedVenues[venueIndex - 1] ?? [];
const [next] = matchedVenues[venueIndex + 1] ?? [];
return { venue, availability, next, previous };
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const [previous] = matchedVenues[venueIndex - 1] ?? [];
const [next] = matchedVenues[venueIndex + 1] ?? [];
return { venue, availability, next, previous };
return { venue, availability, next: matchedVenues[venueIndex - 1], previous: matchedVenues[venueIndex + 1] };

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh you'll need to pull out the 0th value, so it'll look more like

Suggested change
const [previous] = matchedVenues[venueIndex - 1] ?? [];
const [next] = matchedVenues[venueIndex + 1] ?? [];
return { venue, availability, next, previous };
return {
venue,
availability,
next: matchedVenues[venueIndex - 1]?.[0],
previous: matchedVenues[venueIndex + 1]?.[0],
};

which yeah I think is pretty clear as well. Let's use this?

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually I think the original code is clearest as it shows a clearer relationship between previous, next, and the const [venue, availability] = matchedVenues[venueIndex]; line. I think we should leave it as that.

Alternatively:

Suggested change
const [previous] = matchedVenues[venueIndex - 1] ?? [];
const [next] = matchedVenues[venueIndex + 1] ?? [];
return { venue, availability, next, previous };
return {
venue: matchedVenues[venueIndex][0],
availability: matchedVenues[venueIndex][1],
next: matchedVenues[venueIndex - 1]?.[0],
previous: matchedVenues[venueIndex + 1]?.[0],
};

which I think is less clear.

Copy link
Member

Choose a reason for hiding this comment

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

Nah I think the tuple destructure version is still the clearest. Might be clearer if you explicitly name the default tuple (since it might be confused with the parent array) but that's minor

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm personally I think naming the default tuple will be less clear since readers will need to look up what the default tuple actually is

But okay anyway I think we're all in agreement that we should just leave this unchanged?

@taneliang
Copy link
Member Author

All done! I'll wait for #3040 to be done before merging this stack, so that master doesn't end up with a slow venues page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants