diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70eabc8d5..cea82f4ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, StrictMode } from "react"; +import React, { ReactNode, StrictMode, Suspense } from "react"; import { RelayEnvironmentProvider } from "react-relay/hooks"; import { CacheProvider } from "@emotion/react"; import createEmotionCache from "@emotion/cache"; @@ -19,6 +19,7 @@ import { } from "@opencast/appkit"; import { COLORS } from "./color"; import { InitialConsent } from "./ui/InitialConsent"; +import { InitialLoading } from "./layout/Root"; type Props = { @@ -39,7 +40,9 @@ export const App: React.FC = ({ initialRoute, consentGiven }) => ( - + }> + + diff --git a/frontend/src/layout/Root.tsx b/frontend/src/layout/Root.tsx index 96b345327..081fa03a3 100644 --- a/frontend/src/layout/Root.tsx +++ b/frontend/src/layout/Root.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, Suspense, useEffect, useRef } from "react"; +import React, { ReactNode, useEffect, useMemo, useRef } from "react"; import { keyframes } from "@emotion/react"; import { useTranslation } from "react-i18next"; import { screenWidthAtMost } from "@opencast/appkit"; @@ -130,13 +130,7 @@ type RootLoaderProps = { }; /** Entry point for almost all routes: loads the GraphQL query and renders the main page layout */ -export const RootLoader = (props: RootLoaderProps) => ( - }> - - -); - -export const RootLoaderImpl = ({ +export const RootLoader = ({ query, queryRef, nav, @@ -159,11 +153,19 @@ export const RootLoaderImpl = ({ return undefined; })); + // Unfortunately, `` and `` are still rendered + // more than they need to on router navigation. I could not figure out how + // to fix that. So here, we at least memoize the rendering of the whole + // page, so that we don't rerun expensive rendering. + const content = useMemo(() => ( + + {render(data)} + + ), [render, nav, data]); + return ( - - {render(data)} - + {content} ); }; diff --git a/frontend/src/layout/header/Search.tsx b/frontend/src/layout/header/Search.tsx index b3ef9dcb6..cdb5733e9 100644 --- a/frontend/src/layout/header/Search.tsx +++ b/frontend/src/layout/header/Search.tsx @@ -4,7 +4,7 @@ import { HiOutlineSearch } from "react-icons/hi"; import { ProtoButton, screenWidthAtMost } from "@opencast/appkit"; import { LuX } from "react-icons/lu"; -import { useRouter } from "../../router"; +import { useRouter, useRouterState } from "../../router"; import { handleCancelSearch, SearchRoute, @@ -27,6 +27,7 @@ type SearchFieldProps = { export const SearchField: React.FC = ({ variant }) => { const { t } = useTranslation(); const router = useRouter(); + const { isTransitioning } = useRouterState(); const ref = useRef(null); // If the user is unknown, then we are still in the initial loading phase. @@ -177,11 +178,11 @@ export const SearchField: React.FC = ({ variant }) => { /> - {router.isTransitioning && isSearchActive() && } - {!router.isTransitioning && isSearchActive() && handleCancelSearch(router, ref)} css={{ ":hover, :focus": { diff --git a/frontend/src/rauta.tsx b/frontend/src/rauta.tsx index f7bb65dc5..fe891a435 100644 --- a/frontend/src/rauta.tsx +++ b/frontend/src/rauta.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useTransition } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { bug } from "@opencast/appkit"; @@ -81,6 +81,9 @@ export type RouterLib = { /** Hook to obtain a reference to the router. */ useRouter: () => RouterControl; + /** Hook to obtain the router state. */ + useRouterState: () => RouterState; + /** * An internal link, using the defined routes. Should be used instead of * ``. Has to be mounted below a ``! @@ -177,18 +180,20 @@ export interface RouterControl { */ listenBeforeNav(listener: BeforeNavListener): () => void; - /** - * Indicates whether we are currently transitioning to a new route. Intended - * to show a loading indicator. - */ - isTransitioning: boolean; - /** * Indicates whether a user navigated to the current route from outside Tobira. */ internalOrigin: boolean; } +export type RouterState = { + /** + * Indicates whether we are currently transitioning to a new route. Intended + * to show a loading indicator. + */ + isTransitioning: boolean; +}; + export const makeRouter = (config: C): RouterLib => { // Helper to log debug messages if `config.debug` is true. const debugLog = (...args: unknown[]) => { @@ -254,7 +259,6 @@ export const makeRouter = (config: C): RouterLib => { } return { - isTransitioning: context.isTransitioning, push, replace, listenAtNav: (listener: AtNavListener) => @@ -340,12 +344,24 @@ export const makeRouter = (config: C): RouterLib => { atNav: Listeners; beforeNav: Listeners; }; - isTransitioning: boolean; }; const Context = React.createContext(null); + type StateContextData = { + isTransitioning: boolean; + }; + const StateContext = React.createContext(null); + const useRouter = (): RouterControl => useRouterImpl("`useRouter`"); + const useRouterState = (): RouterState => { + const context = React.useContext(StateContext); + if (context === null) { + return bug("useRouterState used without a parent ! That's not allowed."); + } + + return context; + }; /** Provides the required context for `` and `` components. */ const Router = ({ initialRoute, children }: RouterProps) => { @@ -364,7 +380,7 @@ export const makeRouter = (config: C): RouterLib => { // `StrictMode` work, as with that, this component might be unmounted // for reasons other than a route change. const navigatedAway = useRef(false); - const setActiveRoute = (newRoute: ActiveRoute) => { + const setActiveRoute = useCallback((newRoute: ActiveRoute) => { navigatedAway.current = true; startTransition(() => { setActiveRouteRaw(() => newRoute); @@ -373,7 +389,7 @@ export const makeRouter = (config: C): RouterLib => { newRoute: newRoute.route.route, }]); }); - }; + }, [navigatedAway, setActiveRouteRaw, listeners]); // Register some event listeners and set global values. useEffect(() => { @@ -466,14 +482,17 @@ export const makeRouter = (config: C): RouterLib => { } }, [activeRoute, navigatedAway]); - const contextData = { + const contextData = useMemo(() => ({ setActiveRoute, activeRoute, listeners: listeners.current, - isTransitioning: isPending, - }; + }), [activeRoute, setActiveRoute, listeners]); - return {children}; + return + + {children} + + ; }; const ActiveRoute = () => { @@ -490,7 +509,8 @@ export const makeRouter = (config: C): RouterLib => { } }, [context.activeRoute]); - return context.activeRoute.route.matchedRoute.render(); + // Rendered via JSX, as just calling `render()` causes unnecessary rerenders + return ; }; return { @@ -498,6 +518,7 @@ export const makeRouter = (config: C): RouterLib => { matchRoute, matchInitialRoute, useRouter, + useRouterState, ActiveRoute, Router, }; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index a7cb6cf41..9cae3edc2 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -39,6 +39,7 @@ const { matchRoute, Router, useRouter, + useRouterState, } = makeRouter({ fallback: NotFoundRoute, routes: [ @@ -72,7 +73,7 @@ const { ], }); -export { ActiveRoute, Link, matchInitialRoute, matchRoute, Router, useRouter }; +export { ActiveRoute, Link, matchInitialRoute, matchRoute, Router, useRouter, useRouterState }; type LinkProps = { to: string; diff --git a/frontend/src/ui/LoadingIndicator.tsx b/frontend/src/ui/LoadingIndicator.tsx index 98dcacbb4..d8f406e1c 100644 --- a/frontend/src/ui/LoadingIndicator.tsx +++ b/frontend/src/ui/LoadingIndicator.tsx @@ -2,14 +2,14 @@ import { useRef } from "react"; import { Transition } from "react-transition-group"; import { match } from "@opencast/appkit"; -import { useRouter } from "../router"; import { isSearchActive } from "../routes/Search"; +import { useRouterState } from "../router"; import { COLORS } from "../color"; /** A thin colored line at the top of the page indicating a page load */ export const LoadingIndicator: React.FC = () => { - const router = useRouter(); + const { isTransitioning } = useRouterState(); const ref = useRef(null); // If search is active, there is a loading indicator next to the search input. @@ -21,7 +21,7 @@ export const LoadingIndicator: React.FC = () => { const EXIT_DURATION = 150; // TODO: maybe disable this for `prefers-reduced-motion: reduce` - return {state => ( + return {state => (