Skip to content

Commit 55ab10a

Browse files
committed
fix: make sure popover closes before navigation
1 parent 5ecbbe6 commit 55ab10a

File tree

2 files changed

+135
-52
lines changed

2 files changed

+135
-52
lines changed

site/src/components/PopoverContainer/PopoverContainer.tsx

Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,138 @@
11
/**
2-
* @file Abstracts over MUI's Popover component to simplify using it (and hide)
3-
* some of the wonkier parts of the API.
2+
* @file Abstracts over MUI's Popover component to simplify using it (and hide
3+
* some of the wonkier parts of the API).
44
*
55
* Just place a button and some content in the component, and things just work.
66
* No setup needed with hooks or refs.
77
*/
88
import {
99
type KeyboardEvent,
10+
type MouseEvent,
11+
type PropsWithChildren,
1012
type ReactElement,
13+
createContext,
14+
useCallback,
15+
useContext,
1116
useEffect,
1217
useRef,
1318
useState,
14-
PropsWithChildren,
1519
} from "react";
1620

17-
import { type Theme, type SystemStyleObject } from "@mui/system";
21+
import { type Theme, type SystemStyleObject, Box } from "@mui/system";
1822
import Popover, { type PopoverOrigin } from "@mui/material/Popover";
23+
import { useNavigate, type LinkProps } from "react-router-dom";
24+
import { useTheme } from "@emotion/react";
1925

20-
type Props = PropsWithChildren<{
26+
function getButton(container: HTMLElement) {
27+
return (
28+
container.querySelector("button") ??
29+
container.querySelector('[aria-role="button"]')
30+
);
31+
}
32+
33+
const ClosePopoverContext = createContext<(() => void) | null>(null);
34+
35+
type PopoverLinkProps = LinkProps & {
36+
to: string;
37+
sx?: SystemStyleObject<Theme>;
38+
};
39+
40+
/**
41+
* A custom version of a React Router Link that makes sure to close the popover
42+
* before starting a navigation.
43+
*
44+
* This is necessary because React Router's navigation logic doesn't work well
45+
* with modals (including MUI's base Popover component).
46+
*
47+
* ---
48+
* If the page being navigated to has lazy loading and isn't available yet, the
49+
* previous components are supposed to be hidden during the transition, but
50+
* because most React modals use React.createPortal to put content outside of
51+
* the main DOM tree, React Router has no way of knowing about them. So open
52+
* modals have a high risk of not disappearing until the page transition
53+
* finishes and the previous components fully unmount.
54+
*/
55+
export function PopoverLink({
56+
children,
57+
to,
58+
sx,
59+
...linkProps
60+
}: PopoverLinkProps) {
61+
const closePopover = useContext(ClosePopoverContext);
62+
if (closePopover === null) {
63+
throw new Error("PopoverLink is not located inside of a PopoverContainer");
64+
}
65+
66+
// Luckily, useNavigate and Link are designed to be imperative/declarative
67+
// mirrors of each other, so their inputs should never get out of sync
68+
const navigate = useNavigate();
69+
const theme = useTheme();
70+
71+
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
72+
event.preventDefault();
73+
event.stopPropagation();
74+
closePopover();
75+
76+
// Hacky, but by using a promise to push the navigation to resolve via the
77+
// micro-task queue, there's guaranteed to be a period for the popover to
78+
// close. Tried React DOM's flushSync function, but it was unreliable.
79+
void Promise.resolve().then(() => {
80+
navigate(to, linkProps);
81+
});
82+
};
83+
84+
return (
85+
<Box
86+
component="a"
87+
// Href still needed for accessibility reasons and semantic markup
88+
href="javascript:void(0)"
89+
onClick={onClick}
90+
sx={{
91+
outline: "none",
92+
textDecoration: "none",
93+
"&:focus": {
94+
backgroundColor: theme.palette.action.focus,
95+
},
96+
"&:hover": {
97+
textDecoration: "none",
98+
backgroundColor: theme.palette.action.hover,
99+
},
100+
...sx,
101+
}}
102+
>
103+
{children}
104+
</Box>
105+
);
106+
}
107+
108+
type PopoverContainerProps = PropsWithChildren<{
21109
/**
22110
* Does not require any hooks or refs to work. Also does not override any refs
23111
* or event handlers attached to the button.
24112
*/
25113
anchorButton: ReactElement;
114+
26115
width?: number;
27116
originX?: PopoverOrigin["horizontal"];
28117
originY?: PopoverOrigin["vertical"];
29118
sx?: SystemStyleObject<Theme>;
30119
}>;
31120

32-
function getButton(container: HTMLElement) {
33-
return (
34-
container.querySelector("button") ??
35-
container.querySelector('[aria-role="button"]')
36-
);
37-
}
38-
39121
export function PopoverContainer({
40122
children,
41123
anchorButton,
42124
originX = 0,
43125
originY = 0,
44126
width = 320,
45127
sx = {},
46-
}: Props) {
128+
}: PopoverContainerProps) {
129+
const parentClosePopover = useContext(ClosePopoverContext);
130+
if (parentClosePopover !== null) {
131+
throw new Error(
132+
"Popover detected inside of Popover - this will always be a bad user experience",
133+
);
134+
}
135+
47136
const buttonContainerRef = useRef<HTMLDivElement>(null);
48137

49138
// Ref value is for effects and event listeners; state value is for React
@@ -107,6 +196,10 @@ export function PopoverContainer({
107196
}
108197
};
109198

199+
const closePopover = useCallback(() => {
200+
setLoadedButton(undefined);
201+
}, []);
202+
110203
return (
111204
<>
112205
{/* Cannot switch with Box component; breaks implementation */}
@@ -124,26 +217,28 @@ export function PopoverContainer({
124217
{anchorButton}
125218
</div>
126219

127-
<Popover
128-
open={loadedButton !== undefined}
129-
anchorEl={loadedButton}
130-
onClose={() => setLoadedButton(undefined)}
131-
anchorOrigin={{ horizontal: originX, vertical: originY }}
132-
sx={{
133-
"& .MuiPaper-root": {
134-
overflowY: "hidden",
135-
width,
136-
paddingY: 0,
137-
...sx,
138-
},
139-
}}
140-
transitionDuration={{
141-
enter: 300,
142-
exit: 0,
143-
}}
144-
>
145-
{children}
146-
</Popover>
220+
<ClosePopoverContext.Provider value={closePopover}>
221+
<Popover
222+
open={loadedButton !== undefined}
223+
anchorEl={loadedButton}
224+
onClose={closePopover}
225+
anchorOrigin={{ horizontal: originX, vertical: originY }}
226+
sx={{
227+
"& .MuiPaper-root": {
228+
overflowY: "hidden",
229+
width,
230+
paddingY: 0,
231+
...sx,
232+
},
233+
}}
234+
transitionDuration={{
235+
enter: 300,
236+
exit: 0,
237+
}}
238+
>
239+
{children}
240+
</Popover>
241+
</ClosePopoverContext.Provider>
147242
</>
148243
);
149244
}

site/src/pages/WorkspacesPage/WorkspacesButton.tsx

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode, useState } from "react";
1+
import { type ReactNode, useState } from "react";
22
import { useOrganizationId, usePermissions } from "hooks";
33

44
import { useQuery } from "@tanstack/react-query";
@@ -13,11 +13,14 @@ import AddIcon from "@mui/icons-material/AddOutlined";
1313
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
1414

1515
import { Loader } from "components/Loader/Loader";
16-
import { PopoverContainer } from "components/PopoverContainer/PopoverContainer";
1716
import { OverflowY } from "components/OverflowY/OverflowY";
1817
import { EmptyState } from "components/EmptyState/EmptyState";
1918
import { Avatar } from "components/Avatar/Avatar";
2019
import { SearchBox } from "./WorkspacesSearchBox";
20+
import {
21+
PopoverContainer,
22+
PopoverLink,
23+
} from "components/PopoverContainer/PopoverContainer";
2124

2225
const ICON_SIZE = 18;
2326
const COLUMN_GAP = 1.5;
@@ -40,22 +43,7 @@ function sortTemplatesByUsersDesc(
4043

4144
function WorkspaceResultsRow({ template }: { template: Template }) {
4245
return (
43-
<Link
44-
component={RouterLink}
45-
// Sending user directly to workspace creation page for UX
46-
// reasons; avoids extra clicks on the user's part
47-
to={`/templates/${template.name}/workspace`}
48-
sx={{
49-
outline: "none",
50-
"&:focus": {
51-
backgroundColor: (theme) => theme.palette.action.focus,
52-
},
53-
"&:hover": {
54-
textDecoration: "none",
55-
backgroundColor: (theme) => theme.palette.action.hover,
56-
},
57-
}}
58-
>
46+
<PopoverLink to={`/templates/${template.name}/workspace`}>
5947
<Box
6048
sx={{
6149
display: "flex",
@@ -122,7 +110,7 @@ function WorkspaceResultsRow({ template }: { template: Template }) {
122110
</Box>
123111
</Box>
124112
</Box>
125-
</Link>
113+
</PopoverLink>
126114
);
127115
}
128116

0 commit comments

Comments
 (0)