♻️(frontend) rework how we handle screens and layout code

Previously each route rendered its whole layout from a to z.
Now each route updates a single common wrapper when the layout changes
between pages.

Also the Loading, Error, UserFetched, QueryAware code is more explicit
and understandable.

This introduces valtio as dependency, allowing us to deal with global
state easily in a svelte/vue way (reactive mutable state). This limits
the boilerplaty-ness of immutable state lib approaches, while keeping
rendering optimization better than homemade react contexts.
This commit is contained in:
Emmanuel Pelletier
2024-07-29 18:32:56 +02:00
parent 952e6970f0
commit 1f57adc4da
30 changed files with 387 additions and 239 deletions

View File

@@ -23,6 +23,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {
@@ -3643,13 +3644,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"dev": true
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -5134,7 +5135,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -5282,6 +5283,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/derive-valtio": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.1.0.tgz",
"integrity": "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==",
"peerDependencies": {
"valtio": "*"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -8937,6 +8946,11 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/proxy-compare": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10101,6 +10115,39 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz",
"integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==",
"dependencies": {
"derive-valtio": "0.1.0",
"proxy-compare": "2.6.0",
"use-sync-external-store": "1.2.0"
},
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/valtio/node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/value-or-function": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz",

View File

@@ -26,6 +26,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {

View File

@@ -6,8 +6,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { NotFoundScreen } from './layout/NotFoundScreen'
import { RenderIfUserFetched } from './features/auth'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
@@ -19,14 +19,14 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<RenderIfUserFetched>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</RenderIfUserFetched>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>

View File

@@ -0,0 +1,14 @@
import { Link } from '@/primitives'
import { AProps } from '@/primitives/A'
import { useTranslation } from 'react-i18next'
export const BackToHome = ({ size }: { size?: AProps['size'] }) => {
const { t } = useTranslation()
return (
<p>
<Link to="/" size={size}>
{t('backToHome')}
</Link>
</p>
)
}

View File

@@ -0,0 +1,24 @@
import { useState, useEffect, type ReactNode } from 'react'
export const DelayedRender = ({
children,
delay = 500,
}: {
delay?: number
children: ReactNode
}) => {
const [show, setShow] = useState(false)
useEffect(() => {
if (delay === 0) {
setShow(true)
return
}
const timeout = setTimeout(() => setShow(true), delay)
return () => clearTimeout(timeout)
}, [delay])
if (delay !== 0 && !show) {
return null
}
return children
}

View File

@@ -0,0 +1,12 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
export const ErrorScreen = () => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={t('error.heading')} withBackButton />
</Screen>
)
}

View File

@@ -0,0 +1,27 @@
import { Screen, type ScreenProps } from '@/layout/Screen'
import { DelayedRender } from './DelayedRender'
import { CenteredContent } from '@/layout/CenteredContent'
import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
delay = 500,
header = undefined,
layout = 'centered',
}: {
delay?: number
} & Omit<ScreenProps, 'children'>) => {
const { t } = useTranslation()
return (
<DelayedRender delay={delay}>
<Screen layout={layout} header={header}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
</Center>
</CenteredContent>
</Screen>
</DelayedRender>
)
}

View File

@@ -0,0 +1,12 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={t('notFound.heading')} withBackButton />
</Screen>
)
}

View File

@@ -0,0 +1,28 @@
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Render an error or loading Screen while a given `status` is not a success,
* otherwise directly render children.
*
* `status` matches react query statuses.
*
* Children usually contain a Screen at some point in the render tree.
*/
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen header={undefined} />
}
return children
}

View File

@@ -1,17 +0,0 @@
import { type ReactNode } from 'react'
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/layout/LoadingScreen'
/**
* wrapper that renders children only when user info has been actually fetched
*
* this is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const RenderIfUserFetched = ({ children }: { children: ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen renderTimeout={1000} />
)
}

View File

@@ -0,0 +1,20 @@
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Renders a loading Screen while user info has not been fetched yet,
* otherwise directly render children.
*
* Children usually contain a Screen at some point in the render tree.
*
* This is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const UserAware = ({ children }: { children: React.ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen header={false} delay={1000} />
)
}

View File

@@ -1,4 +1,4 @@
export { useUser } from './api/useUser'
export { authUrl } from './utils/authUrl'
export { logoutUrl } from './utils/logoutUrl'
export { RenderIfUserFetched } from './components/RenderIfUserFetched'
export { UserAware } from './components/UserAware'

View File

@@ -1,20 +1,21 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { Button, Div, Text, VerticallyOffCenter } from '@/primitives'
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser } from '@/features/auth'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
return (
<Screen>
<VerticallyOffCenter>
<Div margin="auto" width="fit-content">
<UserAware>
<Screen>
<Centered width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
@@ -49,8 +50,8 @@ export const Home = () => {
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Div>
</VerticallyOffCenter>
</Screen>
</Centered>
</Screen>
</UserAware>
)
}

View File

@@ -8,7 +8,8 @@ import {
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { navigateTo } from '@/navigation/navigateTo'
import { QueryAware } from '@/layout/QueryAware'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { fetchRoom } from '../api/fetchRoom'
import { InviteDialog } from './InviteDialog'
@@ -67,24 +68,26 @@ export const Conference = ({
return (
<QueryAware status={status}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
</LiveKitRoom>
<Screen>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
</LiveKitRoom>
</Screen>
</QueryAware>
)
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Box } from '@/layout/Box'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const Join = ({
onSubmit,
@@ -10,15 +11,17 @@ export const Join = ({
const { t } = useTranslation('rooms')
return (
<Box title={t('join.heading')} withBackButton>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</Box>
<Screen layout="centered">
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</CenteredContent>
</Screen>
)
}

View File

@@ -1,19 +1,15 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from '@/layout/BoxScreen'
import { Div, Link, P } from '@/primitives'
import { P } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
return (
<BoxScreen title={t('feedback.heading')}>
<Div textAlign="left">
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<P>{t('feedback.body')}</P>
</Div>
<Div marginTop={1}>
<P>
<Link to="/">{t('backToHome', { ns: 'global' })}</Link>
</P>
</Div>
</BoxScreen>
</CenteredContent>
</Screen>
)
}

View File

@@ -4,9 +4,8 @@ import {
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { Screen } from '@/layout/Screen'
import { ErrorScreen } from '@/layout/ErrorScreen'
import { useUser } from '@/features/auth'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
@@ -25,21 +24,23 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) {
return (
<Screen>
<UserAware>
<Join onSubmit={setUserConfig} />
</Screen>
</UserAware>
)
}
return (
<Conference
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...(skipJoinScreen ? { username: user?.email as string } : {}),
...userConfig,
}}
/>
<UserAware>
<Conference
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...(skipJoinScreen ? { username: user?.email as string } : {}),
...userConfig,
}}
/>
</UserAware>
)
}

View File

@@ -1,32 +0,0 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Box as BoxDiv, H, Link, VerticallyOffCenter } from '@/primitives'
export type BoxProps = {
children?: ReactNode
title?: ReactNode
withBackButton?: boolean
}
export const Box = ({
children,
title = '',
withBackButton = false,
}: BoxProps) => {
const { t } = useTranslation()
return (
<VerticallyOffCenter>
<BoxDiv type="screen">
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="sm">
{t('backToHome')}
</Link>
</p>
)}
</BoxDiv>
</VerticallyOffCenter>
)
}

View File

@@ -1,10 +0,0 @@
import { Screen } from './Screen'
import { Box, type BoxProps } from './Box'
export const BoxScreen = (props: BoxProps) => {
return (
<Screen>
<Box {...props} />
</Screen>
)
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '../styled-system/types'
export const Centered = ({
width = '38rem',
children,
}: {
width?: SystemStyleObject['width']
children?: ReactNode
}) => {
return (
<VerticallyOffCenter>
<Div margin="auto" width={width} maxWidth="100%">
{children}
</Div>
</VerticallyOffCenter>
)
}

View File

@@ -0,0 +1,29 @@
import { BackToHome } from '@/components/BackToHome'
import { H } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const CenteredContent = ({
title,
children,
withBackButton,
}: {
title?: string
children?: React.ReactNode
withBackButton?: boolean
}) => {
return (
<>
{!!title && (
<Center>
<H lvl={1}>{title}</H>
</Center>
)}
{children}
{!!withBackButton && (
<Center marginTop={2}>
<BackToHome size="sm" />
</Center>
)}
</>
)
}

View File

@@ -1,7 +0,0 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ErrorScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('error.heading')} withBackButton />
}

View File

@@ -1,7 +0,0 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ForbiddenScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('forbidden.heading')} withBackButton />
}

View File

@@ -0,0 +1,42 @@
import { type ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
export type Layout = 'fullpage' | 'centered'
/**
* Layout component for the app.
*
* This component is meant to be used as a wrapper around the whole app.
* In a specific page, use the `Screen` component and change its props to change global page layout.
*/
export const Layout = ({ children }: { children: ReactNode }) => {
const layoutSnap = useSnapshot(layoutStore)
const showHeader = layoutSnap.showHeader
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
color: 'default.text',
})}
>
{showHeader && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}
</main>
</div>
)
}

View File

@@ -1,35 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
import { Screen } from './Screen'
import { VerticallyOffCenter } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
asBox = false,
renderTimeout = 500,
}: {
asBox?: boolean
renderTimeout?: number
}) => {
const { t } = useTranslation()
// show the loading screen only after a little while to prevent flash of texts
const [show, setShow] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setShow(true), renderTimeout)
return () => clearTimeout(timeout)
}, [renderTimeout])
if (!show) {
return null
}
const Container = asBox ? BoxScreen : Screen
return (
<Container>
<VerticallyOffCenter>
<Center>
<p>{t('loading')}</p>
</Center>
</VerticallyOffCenter>
</Container>
)
}

View File

@@ -1,7 +0,0 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('notFound.heading')} withBackButton />
}

View File

@@ -1,21 +0,0 @@
import { ErrorScreen } from './ErrorScreen'
import { LoadingScreen } from './LoadingScreen'
import { Screen } from './Screen'
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen />
}
return <Screen>{children}</Screen>
}

View File

@@ -1,35 +1,30 @@
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { Layout } from './Layout'
import { useEffect } from 'react'
import { Centered } from './Centered'
export type ScreenProps = {
/**
* 'fullpage' by default.
*/
layout?: Layout
/**
* Show header or not.
* True by default. Pass undefined to render the screen without modifying current header visibility
*/
header?: boolean
children: React.ReactNode
}
export const Screen = ({
type,
layout = 'fullpage',
header = true,
children,
}: {
type?: 'splash'
children?: ReactNode
}) => {
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: type === 'splash' ? 'white' : 'default.bg',
color: 'default.text',
})}
>
{type !== 'splash' && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}
</main>
</div>
)
}: ScreenProps) => {
useEffect(() => {
if (header !== undefined) {
layoutStore.showHeader = header
}
}, [header])
return layout === 'centered' ? <Centered>{children}</Centered> : children
}

View File

@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
showHeader: boolean
}
export const layoutStore = proxy<State>({
showHeader: false,
})

View File

@@ -145,6 +145,7 @@
[data-lk-theme] .lk-prejoin {
padding-top: 0;
width: 100%;
}
[data-lk-theme] .lk-participant-tile {