Skip to content

Commit ccd76c9

Browse files
gaojudeacdlite
andauthored
feat: useLinkStatus (#77300)
# `useLinkStatus` Hook The `useLinkStatus` hook is primarily useful when: - Links have `prefetch={false}` set - Navigation occurs before prefetching has completed - When React decides to [skip the loading state](https://react.dev/reference/react/useTransition#preventing-unwanted-loading-indicators) and #69625 It provides loading state feedback during these navigation transitions, preventing pages from appearing "frozen" while new content loads. ## Usage `useLinkStatus` can be called from any descendant component of `Link` and it returns `{ pending: true }` before history has updated. After the URL updates, it returns `{ pending: false }`. ```jsx // When prefetching is disabled <Link href="/dashboard/reports" prefetch={false}> Reports <LoadingIndicator /> </Link> function LoadingIndicator() { const { pending } = useLinkStatus(); return pending ? <Spinner /> : null; } ``` ## Notes - If the link has been prefetched, pending change will be skipped and remain `{ pending: false }`. - If `useLinkStatus` is not under any `Link`, it will always return `{ pending: false }`. - `useLinkStatus` currently does not support Pages Router, so it will always return `{ pending: false }` in Pages Router. - When you click multiple links in a short period, only the last link's pending state is shown --------- Co-authored-by: Andrew Clark <[email protected]>
1 parent 8f68d03 commit ccd76c9

File tree

12 files changed

+578
-83
lines changed

12 files changed

+578
-83
lines changed

Diff for: packages/next/src/client/app-dir/form.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
hasUnsupportedSubmitterAttributes,
1717
type FormProps,
1818
} from '../form-shared'
19-
import { mountLinkInstance, unmountLinkInstance } from '../components/links'
19+
import {
20+
mountFormInstance,
21+
unmountPrefetchableInstance,
22+
} from '../components/links'
2023

2124
export type { FormProps }
2225

@@ -96,10 +99,10 @@ export default function Form({
9699
const observeFormVisibilityOnMount = useCallback(
97100
(element: HTMLFormElement) => {
98101
if (isPrefetchEnabled && router !== null) {
99-
mountLinkInstance(element, actionProp, router, PrefetchKind.AUTO)
102+
mountFormInstance(element, actionProp, router, PrefetchKind.AUTO)
100103
}
101104
return () => {
102-
unmountLinkInstance(element)
105+
unmountPrefetchableInstance(element)
103106
}
104107
},
105108
[isPrefetchEnabled, actionProp, router]

Diff for: packages/next/src/client/app-dir/link.tsx

+55-30
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
'use client'
22

3-
import type { NextRouter } from '../../shared/lib/router/router'
4-
5-
import React from 'react'
3+
import React, { createContext, useContext, useOptimistic, useRef } from 'react'
64
import type { UrlObject } from 'url'
75
import { formatUrl } from '../../shared/lib/router/utils/format-url'
86
import { AppRouterContext } from '../../shared/lib/app-router-context.shared-runtime'
9-
import type { AppRouterInstance } from '../../shared/lib/app-router-context.shared-runtime'
107
import { PrefetchKind } from '../components/router-reducer/router-reducer-types'
118
import { useMergedRef } from '../use-merged-ref'
129
import { isAbsoluteUrl } from '../../shared/lib/utils'
1310
import { addBasePath } from '../add-base-path'
1411
import { warnOnce } from '../../shared/lib/utils/warn-once'
12+
import type { PENDING_LINK_STATUS } from '../components/links'
1513
import {
14+
IDLE_LINK_STATUS,
1615
mountLinkInstance,
1716
onNavigationIntent,
18-
unmountLinkInstance,
17+
unmountLinkForCurrentNavigation,
18+
unmountPrefetchableInstance,
19+
type LinkInstance,
1920
} from '../components/links'
2021
import { isLocalURL } from '../../shared/lib/router/utils/is-local-url'
22+
import { dispatchNavigateAction } from '../components/app-router-instance'
2123

2224
type Url = string | UrlObject
2325
type RequiredKeys<T> = {
@@ -228,11 +230,10 @@ function isModifiedEvent(event: React.MouseEvent): boolean {
228230

229231
function linkClicked(
230232
e: React.MouseEvent,
231-
router: NextRouter | AppRouterInstance,
232233
href: string,
233234
as: string,
235+
linkInstanceRef: React.RefObject<LinkInstance | null>,
234236
replace?: boolean,
235-
shallow?: boolean,
236237
scroll?: boolean,
237238
onNavigate?: OnNavigateEventHandler
238239
): void {
@@ -278,20 +279,12 @@ function linkClicked(
278279
}
279280
}
280281

281-
// If the router is an NextRouter instance it will have `beforePopState`
282-
// TODO: This should access the router methods directly, rather than
283-
// go through the public interface.
284-
const routerScroll = scroll ?? true
285-
if ('beforePopState' in router) {
286-
router[replace ? 'replace' : 'push'](href, as, {
287-
shallow,
288-
scroll: routerScroll,
289-
})
290-
} else {
291-
router[replace ? 'replace' : 'push'](as || href, {
292-
scroll: routerScroll,
293-
})
294-
}
282+
dispatchNavigateAction(
283+
as || href,
284+
replace ? 'replace' : 'push',
285+
scroll ?? true,
286+
linkInstanceRef.current
287+
)
295288
}
296289

297290
React.startTransition(navigate)
@@ -321,8 +314,12 @@ export default function LinkComponent(
321314
ref: React.Ref<HTMLAnchorElement>
322315
}
323316
) {
317+
const [linkStatus, setOptimisticLinkStatus] = useOptimistic(IDLE_LINK_STATUS)
318+
324319
let children: React.ReactNode
325320

321+
const linkInstanceRef = useRef<LinkInstance | null>(null)
322+
326323
const {
327324
href: hrefProp,
328325
as: asProp,
@@ -557,14 +554,26 @@ export default function LinkComponent(
557554
// a revalidation or refresh.
558555
const observeLinkVisibilityOnMount = React.useCallback(
559556
(element: HTMLAnchorElement | SVGAElement) => {
560-
if (prefetchEnabled && router !== null) {
561-
mountLinkInstance(element, href, router, appPrefetchKind)
557+
if (router !== null) {
558+
linkInstanceRef.current = mountLinkInstance(
559+
element,
560+
href,
561+
router,
562+
appPrefetchKind,
563+
prefetchEnabled,
564+
setOptimisticLinkStatus
565+
)
562566
}
567+
563568
return () => {
564-
unmountLinkInstance(element)
569+
if (linkInstanceRef.current) {
570+
unmountLinkForCurrentNavigation(linkInstanceRef.current)
571+
linkInstanceRef.current = null
572+
}
573+
unmountPrefetchableInstance(element)
565574
}
566575
},
567-
[prefetchEnabled, href, router, appPrefetchKind]
576+
[prefetchEnabled, href, router, appPrefetchKind, setOptimisticLinkStatus]
568577
)
569578

570579
const mergedRef = useMergedRef(observeLinkVisibilityOnMount, childRef)
@@ -606,7 +615,7 @@ export default function LinkComponent(
606615
return
607616
}
608617

609-
linkClicked(e, router, href, as, replace, shallow, scroll, onNavigate)
618+
linkClicked(e, href, as, linkInstanceRef, replace, scroll, onNavigate)
610619
},
611620
onMouseEnter(e) {
612621
if (!legacyBehavior && typeof onMouseEnterProp === 'function') {
@@ -671,6 +680,8 @@ export default function LinkComponent(
671680
childProps.href = addBasePath(as)
672681
}
673682

683+
let link: React.ReactNode
684+
674685
if (legacyBehavior) {
675686
if (process.env.NODE_ENV === 'development') {
676687
console.error(
@@ -680,12 +691,26 @@ export default function LinkComponent(
680691
'Learn more: https://nextjs.org/docs/app/building-your-application/upgrading/codemods#remove-a-tags-from-link-components'
681692
)
682693
}
683-
return React.cloneElement(child, childProps)
694+
link = React.cloneElement(child, childProps)
695+
} else {
696+
link = (
697+
<a {...restProps} {...childProps}>
698+
{children}
699+
</a>
700+
)
684701
}
685702

686703
return (
687-
<a {...restProps} {...childProps}>
688-
{children}
689-
</a>
704+
<LinkStatusContext.Provider value={linkStatus}>
705+
{link}
706+
</LinkStatusContext.Provider>
690707
)
691708
}
709+
710+
const LinkStatusContext = createContext<
711+
typeof PENDING_LINK_STATUS | typeof IDLE_LINK_STATUS
712+
>(IDLE_LINK_STATUS)
713+
714+
export const useLinkStatus = () => {
715+
return useContext(LinkStatusContext)
716+
}

Diff for: packages/next/src/client/components/app-router-instance.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
NavigateOptions,
2525
PrefetchOptions,
2626
} from '../../shared/lib/app-router-context.shared-runtime'
27+
import { setLinkForCurrentNavigation, type LinkInstance } from './links'
2728

2829
export type DispatchStatePromise = React.Dispatch<ReducerState>
2930

@@ -235,17 +236,21 @@ function getAppRouterActionQueue(): AppRouterActionQueue {
235236
return globalActionQueue
236237
}
237238

238-
function dispatchNavigateAction(
239+
export function dispatchNavigateAction(
239240
href: string,
240241
navigateType: NavigateAction['navigateType'],
241-
shouldScroll: boolean
242+
shouldScroll: boolean,
243+
linkInstanceRef: LinkInstance | null
242244
): void {
243245
// TODO: This stuff could just go into the reducer. Leaving as-is for now
244246
// since we're about to rewrite all the router reducer stuff anyway.
245247
const url = new URL(addBasePath(href), location.href)
246248
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
247249
window.next.__pendingUrl = url
248250
}
251+
252+
setLinkForCurrentNavigation(linkInstanceRef)
253+
249254
dispatchAppRouterAction({
250255
type: ACTION_NAVIGATE,
251256
url,
@@ -298,12 +303,12 @@ export const publicAppRouterInstance: AppRouterInstance = {
298303
},
299304
replace: (href: string, options?: NavigateOptions) => {
300305
startTransition(() => {
301-
dispatchNavigateAction(href, 'replace', options?.scroll ?? true)
306+
dispatchNavigateAction(href, 'replace', options?.scroll ?? true, null)
302307
})
303308
},
304309
push: (href: string, options?: NavigateOptions) => {
305310
startTransition(() => {
306-
dispatchNavigateAction(href, 'push', options?.scroll ?? true)
311+
dispatchNavigateAction(href, 'push', options?.scroll ?? true, null)
307312
})
308313
},
309314
refresh: () => {

0 commit comments

Comments
 (0)