Skip to content

Commit

Permalink
WIP react router 6
Browse files Browse the repository at this point in the history
  • Loading branch information
evanpurkhiser committed May 10, 2024
1 parent 05b36bc commit cba5566
Show file tree
Hide file tree
Showing 14 changed files with 355 additions and 34 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"react-mentions": "4.4.10",
"react-popper": "^2.3.0",
"react-router": "3.2.6",
"react-router-dom": "^6.23.0",
"react-select": "4.3.1",
"react-sparklines": "1.7.0",
"react-virtualized": "^9.22.5",
Expand Down
13 changes: 13 additions & 0 deletions static/app/components/links/link.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {forwardRef} from 'react';
import {Link as RouterLink} from 'react-router';
import {Link as Router6Link} from 'react-router-dom';
import styled from '@emotion/styled';
import type {LocationDescriptor} from 'history';

import {USING_REACT_ROUTER_SIX} from 'sentry/constants';
import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat';
import {useLocation} from 'sentry/utils/useLocation';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';

Expand Down Expand Up @@ -46,6 +49,16 @@ function BaseLink({disabled, to, forwardedRef, ...props}: LinkProps): React.Reac
to = normalizeUrl(to, location);

if (!disabled && location) {
if (USING_REACT_ROUTER_SIX) {
return (
<Router6Link
to={locationDescriptorToTo(to)}
ref={forwardedRef as any}
{...props}
/>
);
}

return <RouterLink to={to} ref={forwardedRef as any} {...props} />;
}

Expand Down
2 changes: 1 addition & 1 deletion static/app/components/sidebar/sidebarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function SidebarItem({

const toProps: LocationDescriptor = useMemo(() => {
return {
pathname: to ? to : href ?? '#',
pathname: to ? to : href,
search,
state: {source: SIDEBAR_NAVIGATION_SOURCE},
};
Expand Down
2 changes: 2 additions & 0 deletions static/app/constants/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,5 @@ export const USE_REACT_QUERY_DEVTOOL = process.env.USE_REACT_QUERY_DEVTOOL;
export const DEFAULT_ERROR_JSON = {
detail: t('Unknown error. Please try again.'),
};

export const USING_REACT_ROUTER_SIX = true;
17 changes: 12 additions & 5 deletions static/app/main.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {Router, RouterContext} from 'react-router';
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import {ReactQueryDevtools} from '@tanstack/react-query-devtools';

import DemoHeader from 'sentry/components/demo/demoHeader';
import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider';
import {USE_REACT_QUERY_DEVTOOL} from 'sentry/constants';
import {routes} from 'sentry/routes';
import {USE_REACT_QUERY_DEVTOOL, USING_REACT_ROUTER_SIX} from 'sentry/constants';
import {routes, routes6} from 'sentry/routes';
import ConfigStore from 'sentry/stores/configStore';
import {browserHistory} from 'sentry/utils/browserHistory';
import {
Expand All @@ -32,15 +33,21 @@ function renderRouter(props: any) {

const queryClient = new QueryClient(DEFAULT_QUERY_CLIENT_CONFIG);

const router = createBrowserRouter(routes6);

function Main() {
return (
<ThemeAndStyleProvider>
<QueryClientProvider client={queryClient}>
<OnboardingContextProvider>
{ConfigStore.get('demoMode') && <DemoHeader />}
<Router history={browserHistory} render={renderRouter}>
{routes()}
</Router>
{USING_REACT_ROUTER_SIX ? (
<RouterProvider router={router} />
) : (
<Router history={browserHistory} render={renderRouter}>
{routes()}
</Router>
)}
</OnboardingContextProvider>
{USE_REACT_QUERY_DEVTOOL && (
<ReactQueryDevtools initialIsOpen={false} position="bottom-left" />
Expand Down
21 changes: 12 additions & 9 deletions static/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import RouteNotFound from 'sentry/views/routeNotFound';
import SettingsWrapper from 'sentry/views/settings/components/settingsWrapper';

import {IndexRoute, Route} from './components/route';
import {buildReactRouter6Routes} from './utils/reactRouter6Compat';

const hook = (name: HookName) => HookStore.get(name).map(cb => cb());

Expand Down Expand Up @@ -697,15 +698,13 @@ function buildRoutes() {
)}
/>
)}
{USING_CUSTOMER_DOMAIN && (
<Route
path="/settings/organization/"
name={t('General')}
component={make(
() => import('sentry/views/settings/organizationGeneralSettings')
)}
/>
)}
<Route
path="organization/"
name={t('General')}
component={make(
() => import('sentry/views/settings/organizationGeneralSettings')
)}
/>
<Route
path="projects/"
name={t('Projects')}
Expand Down Expand Up @@ -2254,6 +2253,10 @@ function buildRoutes() {
// when the app renders Main. Memoize to avoid rebuilding the route tree.
export const routes = memoize(buildRoutes);

// XXX(epurkhiser): Transforms the legacy react-router 3 routest tree into a
// react-router 6 style routes tree.
export const routes6 = buildReactRouter6Routes(buildRoutes());

// Exported for use in tests.
export {buildRoutes};

Expand Down
127 changes: 127 additions & 0 deletions static/app/utils/reactRouter6Compat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {Children, isValidElement} from 'react';
import {Outlet, type RouteObject, type To, useOutletContext} from 'react-router-dom';
import type {LocationDescriptor} from 'history';
import * as qs from 'query-string';

import {useLocation} from './useLocation';
import {useParams} from './useParams';
import useRouter from './useRouter';
import {useRoutes} from './useRoutes';

function isComponent(
element: JSX.Element
): element is React.ReactElement<any, React.NamedExoticComponent<any>> {
return typeof element.type !== 'string';
}

/**
* Because some of our vies use cloneElement to inject route props into the
* children views, we need to capture those props and pass them as outlet
* context. The WithReactRouter3Props HoC component will inject the outlet
* context into the view
*/
function OurOutlet(props: any) {
return <Outlet context={props} />;
}

/**
* HoC which injects params and a route object that emulate react-router3
*/
function withReactRouter3Props(Component: React.ComponentType<any>) {
function WithReactRouter3Props() {
const params = useParams();
const router = useRouter();
const routes = useRoutes();
const location = useLocation();
const outletContext = useOutletContext<{}>();

return (
<Component
router={router}
routes={routes}
params={params}
location={location}
{...outletContext}
>
<OurOutlet />
</Component>
);
}

return WithReactRouter3Props;
}

function getElement(Component: React.ComponentType | undefined) {
if (!Component) {
return undefined;
}

const WrappedComponent = withReactRouter3Props(Component);

return <WrappedComponent />;
}

/**
* Transforms a react-router 3 style route tree into a valid react-router 6
* router tree.
*/
export function buildReactRouter6Routes(tree: JSX.Element) {
const routes: RouteObject[] = [];

Children.forEach(tree, routeNode => {
if (!isValidElement(routeNode)) {
return;
}
if (!isComponent(routeNode)) {
return;
}

const isRoute = routeNode.type.displayName === 'Route';
const isIndexRoute = routeNode.type.displayName === 'IndexRoute';

// Elements that are not Route components are likely fragments, just
// traverse into their children in this case.
if (!isRoute && !isIndexRoute) {
routes.push(...buildReactRouter6Routes(routeNode.props.children));
return;
}

const {path, component: Component, children} = routeNode.props;
const element = getElement(Component);

if (isIndexRoute) {
routes.push({path, element, index: true});
} else {
routes.push({path, element, children: buildReactRouter6Routes(children)});
}
});

return routes;
}

/**
* Translates a react-router 3 LocationDescriptor to a react-router 6 To.
*/
export function locationDescriptorToTo(path: LocationDescriptor): To {
if (typeof path === 'string') {
return path;
}

const to: To = {
pathname: path.pathname,
};

if (path.hash) {
to.hash = path.hash;
}
if (path.search) {
to.search = path.search;
}
if (path.query) {
to.search = `?${qs.stringify(path.query)}`;
}

// XXX(epurkhiser): We ignore the location state param

return to;
}
37 changes: 35 additions & 2 deletions static/app/utils/useLocation.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
import {useMemo} from 'react';
import {useLocation as useReactRouter6Location} from 'react-router-dom';
import type {Location, Query} from 'history';
import * as qs from 'query-string';

import {USING_REACT_ROUTER_SIX} from 'sentry/constants';
import {useRouteContext} from 'sentry/utils/useRouteContext';

type DefaultQuery<T = string> = {
[key: string]: T | T[] | null | undefined;
};

export function useLocation<Q extends Query = DefaultQuery>(): Location<Q> {
const route = useRouteContext();
return route.location;
if (!USING_REACT_ROUTER_SIX) {
// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
return useRouteContext().location;
}

// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
const {pathname, search, hash, state, key} = useReactRouter6Location();

// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
const query = useMemo(() => qs.parse(search) as Q, [search]);

// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
const location = useMemo(
() =>
({
pathname: pathname,
search: search,
query,
hash: hash,
state,
key,

// XXX(epurkhiser): It would be possible to extract this from the
// react-router 6 browserHistory object. But beecause of how we're
// shimming it it's a little hard, so for now just mock
action: 'POP',
}) satisfies Location<Q>,
[hash, key, pathname, query, search, state]
);

return location;
}
21 changes: 17 additions & 4 deletions static/app/utils/useNavigate.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {useCallback, useEffect, useRef} from 'react';
import {
NavigateFunction,
Path,
useNavigate as useReactRouter6Navigate,
} from 'react-router-dom';

import {USING_REACT_ROUTER_SIX} from 'sentry/constants';
import {useRouteContext} from 'sentry/utils/useRouteContext';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';

Expand All @@ -15,15 +21,22 @@ type NavigateOptions = {
* @see https://reactrouter.com/hooks/use-navigate
*/
export function useNavigate() {
if (USING_REACT_ROUTER_SIX) {
return useReactRouter6Navigate();
}

const route = useRouteContext();

const navigator = route.router;
const hasMountedRef = useRef(false);
useEffect(() => {
hasMountedRef.current = true;
});
const navigate = useCallback(
(to: string | number, options: NavigateOptions = {}) => {

// XXX(epurkhiser): Needs to implement location descriptor translation

const navigate: NavigateFunction = useCallback(
(to: string | number | Partial<Path>, options: NavigateOptions = {}) => {
if (!hasMountedRef.current) {
throw new Error(
`You should call navigate() in a React.useEffect(), not when your component is first rendered.`
Expand All @@ -39,10 +52,10 @@ export function useNavigate() {
};

if (options.replace) {
return navigator.replace(nextState);
return navigator.replace(nextState as any);
}

return navigator.push(nextState);
return navigator.push(nextState as any);
},
[navigator]
);
Expand Down
17 changes: 15 additions & 2 deletions static/app/utils/useParams.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import {useMemo} from 'react';
import {useParams as useReactRouter6Params} from 'react-router-dom';

import {CUSTOMER_DOMAIN, USING_CUSTOMER_DOMAIN} from 'sentry/constants';
import {
CUSTOMER_DOMAIN,
USING_CUSTOMER_DOMAIN,
USING_REACT_ROUTER_SIX,
} from 'sentry/constants';
import {useRouteContext} from 'sentry/utils/useRouteContext';

export function useParams<P = Record<string, string>>(): P {
const contextParams = useRouteContext().params;
let contextParams: any;

if (USING_REACT_ROUTER_SIX) {
// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
contextParams = useReactRouter6Params();
} else {
// biome-ignore lint/correctness/useHookAtTopLevel: react-router 6 migration
contextParams = useRouteContext().params;
}

// Memoize params as mutating for customer domains causes other hooks
// that depend on `useParams()` to refresh infinitely.
Expand Down
Loading

0 comments on commit cba5566

Please sign in to comment.