Skip to content

Commit 3f550bc

Browse files
authored
feat: route transitions (#9275)
Due to nature of server-side rendering, navigation within the admin panel can lead to slow page response times. This can lead to the feeling of an unresponsive app after clicking a link, for example, where the page remains in a stale state while the server is processing. This is especially noticeable on slow networks when navigating to data heavy or process intensive pages. To alleviate the bad UX that this causes, the user needs immediate visual indication that _something_ is taking place. This PR renders a progress bar in the admin panel which is immediately displayed when a user clicks a link, and incrementally grows in size until the new route has loaded in. Inspired by https://github.com/vercel/react-transition-progress. Old: https://github.com/user-attachments/assets/1820dad1-3aea-417f-a61d-52244b12dc8d New: https://github.com/user-attachments/assets/99f4bb82-61d9-4a4c-9bdf-9e379bbafd31 To tie into the progress bar, you'll need to use Payload's new `Link` component instead of the one provided by Next.js: ```diff - import { Link } from 'next/link' + import { Link } from '@payloadcms/ui' ``` Here's an example: ```tsx import { Link } from '@payloadcms/ui' const MyComponent = () => { return ( <Link href="/somewhere"> Go Somewhere </Link> ) } ``` In order to trigger route transitions for a direct router event such as `router.push`, you'll need to wrap your function calls with the `startRouteTransition` method provided by the `useRouteTransition` hook. ```ts 'use client' import React, { useCallback } from 'react' import { useTransition } from '@payloadcms/ui' import { useRouter } from 'next/navigation' const MyComponent: React.FC = () => { const router = useRouter() const { startRouteTransition } = useRouteTransition() const redirectSomewhere = useCallback(() => { startRouteTransition(() => router.push('/somewhere')) }, [startRouteTransition, router]) // ... } ``` In the future [Next.js might provide native support for this](vercel/next.js#41934 (comment)), and if it does, this implementation can likely be simplified. Of course there are other ways of achieving this, such as with [Suspense](https://react.dev/reference/react/Suspense), but they all come with a different set of caveats. For example with Suspense, you must provide a fallback component. This means that the user might be able to immediately navigate to the new page, which is good, but they'd be presented with a skeleton UI while the other parts of the page stream in. Not necessarily an improvement to UX as there would be multiple loading states with this approach. There are other problems with using Suspense as well. Our default template, for example, contains the app header and sidebar which are not rendered within the root layout. This means that they need to stream in every single time. On fast networks, this would also lead to a noticeable "blink" unless there is some mechanism by which we can detect and defer the fallback from ever rendering in such cases. Might still be worth exploring in the future though.
1 parent 706410e commit 3f550bc

File tree

53 files changed

+773
-290
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+773
-290
lines changed

docs/admin/hooks.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,5 +1113,40 @@ setParams({ depth: 2 })
11131113

11141114
This is useful for scenarios where you need to trigger another fetch regardless of the `url` argument changing.
11151115

1116+
## useRouteTransition
11161117

1118+
Route transitions are useful in showing immediate visual feedback to the user when navigating between pages. This is especially useful on slow networks when navigating to data heavy or process intensive pages.
11171119

1120+
By default, any instances of `Link` from `@payloadcms/ui` will trigger route transitions dy default.
1121+
1122+
```tsx
1123+
import { Link } from '@payloadcms/ui'
1124+
1125+
const MyComponent = () => {
1126+
return (
1127+
<Link href="/somewhere">
1128+
Go Somewhere
1129+
</Link>
1130+
)
1131+
}
1132+
```
1133+
1134+
You can also trigger route transitions programmatically, such as when using `router.push` from `next/router`. To do this, wrap your function calls with the `startRouteTransition` method provided by the `useRouteTransition` hook.
1135+
1136+
```ts
1137+
'use client'
1138+
import React, { useCallback } from 'react'
1139+
import { useTransition } from '@payloadcms/ui'
1140+
import { useRouter } from 'next/navigation'
1141+
1142+
const MyComponent: React.FC = () => {
1143+
const router = useRouter()
1144+
const { startRouteTransition } = useRouteTransition()
1145+
1146+
const redirectSomewhere = useCallback(() => {
1147+
startRouteTransition(() => router.push('/somewhere'))
1148+
}, [startRouteTransition, router])
1149+
1150+
// ...
1151+
}
1152+
```

packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
'use client'
22
import type { SanitizedConfig } from 'payload'
33

4+
import { Link } from '@payloadcms/ui'
45
import { formatAdminURL } from '@payloadcms/ui/shared'
5-
import LinkImport from 'next/link.js'
66
import { useParams, usePathname, useSearchParams } from 'next/navigation.js'
77
import React from 'react'
88

9-
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
10-
119
export const DocumentTabLink: React.FC<{
1210
adminRoute: SanitizedConfig['routes']['admin']
1311
ariaLabel?: string

packages/next/src/elements/Nav/index.client.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import type { groupNavItems } from '@payloadcms/ui/shared'
44
import type { NavPreferences } from 'payload'
55

66
import { getTranslation } from '@payloadcms/translations'
7-
import { NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
7+
import { Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
88
import { EntityType, formatAdminURL } from '@payloadcms/ui/shared'
9-
import LinkWithDefault from 'next/link.js'
109
import { usePathname } from 'next/navigation.js'
1110
import React, { Fragment } from 'react'
1211

@@ -45,9 +44,6 @@ export const DefaultNavClient: React.FC<{
4544
id = `nav-global-${slug}`
4645
}
4746

48-
const Link = (LinkWithDefault.default ||
49-
LinkWithDefault) as typeof LinkWithDefault.default
50-
5147
const LinkElement = Link || 'a'
5248
const activeCollection =
5349
pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])

packages/next/src/elements/Nav/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { EntityToGroup } from '@payloadcms/ui/shared'
22
import type { ServerProps } from 'payload'
33

4-
import { Logout } from '@payloadcms/ui'
4+
import { Link, Logout } from '@payloadcms/ui'
55
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
66
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
77
import React from 'react'
88

9-
import './index.scss'
109
import { NavHamburger } from './NavHamburger/index.js'
1110
import { NavWrapper } from './NavWrapper/index.js'
11+
import './index.scss'
1212

1313
const baseClass = 'nav'
1414

@@ -73,6 +73,7 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
7373
const LogoutComponent = RenderServerComponent({
7474
clientProps: {
7575
documentSubViewType,
76+
Link,
7677
viewType,
7778
},
7879
Component: logout?.Button,

packages/next/src/layouts/Root/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations'
22
import type { ImportMap, LanguageOptions, SanitizedConfig, ServerFunctionClient } from 'payload'
33

44
import { rtlLanguages } from '@payloadcms/translations'
5-
import { RootProvider } from '@payloadcms/ui'
5+
import { ProgressBar, RootProvider } from '@payloadcms/ui'
66
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
77
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
88
import { getPayload, getRequestLanguage, parseCookies } from 'payload'
@@ -135,6 +135,7 @@ export const RootLayout = async ({
135135
translations={req.i18n.translations}
136136
user={req.user}
137137
>
138+
<ProgressBar />
138139
{Array.isArray(config.admin?.components?.providers) &&
139140
config.admin?.components?.providers.length > 0 ? (
140141
<NestProviders

packages/next/src/views/Dashboard/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import type { EntityToGroup } from '@payloadcms/ui/shared'
22
import type { AdminViewProps } from 'payload'
33

4-
import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui'
4+
import { HydrateAuthProvider, Link, SetStepNav } from '@payloadcms/ui'
55
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
66
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
7-
import LinkImport from 'next/link.js'
87
import React, { Fragment } from 'react'
98

109
import { DefaultDashboard } from './Default/index.js'
1110

1211
export { generateDashboardMetadata } from './meta.js'
1312

14-
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
15-
1613
export const Dashboard: React.FC<AdminViewProps> = async ({
1714
initPageResult,
1815
params,
@@ -119,7 +116,6 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
119116
serverProps: {
120117
globalData,
121118
i18n,
122-
Link,
123119
locale,
124120
navGroups,
125121
params,

packages/next/src/views/ForgotPassword/index.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import type { AdminViewProps } from 'payload'
22

3-
import { Button } from '@payloadcms/ui'
3+
import { Button, Link } from '@payloadcms/ui'
44
import { formatAdminURL, Translation } from '@payloadcms/ui/shared'
5-
import LinkImport from 'next/link.js'
65
import React, { Fragment } from 'react'
76

87
import { FormHeader } from '../../elements/FormHeader/index.js'
98
import { ForgotPasswordForm } from './ForgotPasswordForm/index.js'
109

1110
export { generateForgotPasswordMetadata } from './meta.js'
1211

13-
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
1412
export const forgotPasswordBaseClass = 'forgot-password'
1513

1614
export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult }) => {

packages/next/src/views/LivePreview/index.client.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
useDocumentEvents,
2929
useDocumentInfo,
3030
useEditDepth,
31+
useRouteTransition,
3132
useServerFunctions,
3233
useTranslation,
3334
useUploadEdits,
@@ -131,6 +132,7 @@ const PreviewView: React.FC<Props> = ({
131132
const { reportUpdate } = useDocumentEvents()
132133
const { resetUploadEdits } = useUploadEdits()
133134
const { getFormState } = useServerFunctions()
135+
const { startRouteTransition } = useRouteTransition()
134136

135137
const docConfig = collectionConfig || globalConfig
136138

@@ -211,7 +213,8 @@ const PreviewView: React.FC<Props> = ({
211213
adminRoute,
212214
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
213215
})
214-
router.push(redirectRoute)
216+
217+
startRouteTransition(() => router.push(redirectRoute))
215218
} else {
216219
resetUploadEdits()
217220
}
@@ -269,6 +272,7 @@ const PreviewView: React.FC<Props> = ({
269272
router,
270273
setDocumentIsLocked,
271274
updateSavedDocumentData,
275+
startRouteTransition,
272276
user,
273277
userSlug,
274278
autosaveEnabled,

packages/next/src/views/Login/LoginForm/index.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
'use client'
22

3-
import LinkImport from 'next/link.js'
43
import React from 'react'
54

65
const baseClass = 'login__form'
7-
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
86

97
import type { UserWithToken } from '@payloadcms/ui'
108
import type { FormState } from 'payload'
119

12-
import { Form, FormSubmit, PasswordField, useAuth, useConfig, useTranslation } from '@payloadcms/ui'
10+
import {
11+
Form,
12+
FormSubmit,
13+
Link,
14+
PasswordField,
15+
useAuth,
16+
useConfig,
17+
useTranslation,
18+
} from '@payloadcms/ui'
1319
import { formatAdminURL } from '@payloadcms/ui/shared'
1420
import { getLoginOptions } from 'payload/shared'
1521

packages/next/src/views/Login/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { redirect } from 'next/navigation.js'
55
import React, { Fragment } from 'react'
66

77
import { Logo } from '../../elements/Logo/index.js'
8-
import './index.scss'
98
import { LoginForm } from './LoginForm/index.js'
9+
import './index.scss'
1010

1111
export { generateLoginMetadata } from './meta.js'
1212

0 commit comments

Comments
 (0)