-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: SukkaW <isukkaw@gmail.com>
- Loading branch information
Showing
3 changed files
with
281 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
--- | ||
title: useNextLink (Next.js App Router) | ||
--- | ||
|
||
# useNextLink (Next.js App Router) | ||
|
||
import ExportMetaInfo from '../components/export-meta-info'; | ||
|
||
<ExportMetaInfo /> | ||
|
||
Build your own `next/link` component with all Next.js features you love (client-side navigation between routes, prefetching), plus React's `isPending` transition state to build "navigating..." animation. | ||
|
||
import { Callout } from 'nextra/components' | ||
|
||
<Callout type="warning" emoji="⚠️"> | ||
The `useNextLink` hook is your last resort. You should always prefer `next/link` when possible. This hook can only be used with Next.js App Router, and should only be used when using the `isPending` transition state to build "navigating..." animation. | ||
</Callout> | ||
|
||
```tsx | ||
'use client'; | ||
|
||
import { useRef } from 'react'; | ||
import { unstable_useNextLink as useNextLink } from 'foxact/use-next-link'; | ||
|
||
export default function Page() { | ||
const ref = useRef<HTMLAnchorElement>(null); | ||
|
||
const [isPending, linkProps] = useNextLinkProps( | ||
// `href` | ||
'/about', | ||
// optional, your usual next/link prop like "onClick", "prefetch", "replace" and "scroll" should go here. | ||
{ | ||
// If you want to attach your ref to the link, you should put your "ref" here instead of doing <a ref={ref} {...linkProps} /> | ||
ref | ||
} | ||
); | ||
|
||
return ( | ||
<div> | ||
{isPending && <div>Navigating...</div>} | ||
{/** You can only pass non-"next/link" prop directly to the <a /> */} | ||
{/** All "next/link" prop and "ref" should be passed to useNextLink() */} | ||
<a className="link" {...linkProps}> | ||
About | ||
</a> | ||
</div> | ||
); | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
import 'client-only'; | ||
|
||
import type { UrlObject } from 'url'; | ||
import type { LinkProps } from 'next/link'; | ||
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'; | ||
import { useRouter } from 'next/navigation'; | ||
|
||
import { formatUrl } from 'next/dist/shared/lib/router/utils/format-url'; | ||
import { useIntersection } from '../use-intersection'; | ||
|
||
import type { | ||
PrefetchOptions as AppRouterPrefetchOptions | ||
} from 'next/dist/shared/lib/app-router-context.shared-runtime'; | ||
import type { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types'; | ||
|
||
export interface UseNextLinkOptions extends Omit<LinkProps, | ||
| 'as' // Next.js App Router doesn't encourage to use `as` prop (it is only retained for the legacy puprpose) | ||
| 'href' // `href` is passed in as a separate argument (for easier usage) | ||
| 'legacyBehavior' // Dropping `legacyBehavior` prop can simplify things a lot | ||
| 'shallow' // `shallow` is only for Next.js Pages Router | ||
| 'passHref' // Also `legacyBehavior` | ||
| 'locale' // For Next.js Pages Router's built-in i18n only, Next.js App Router doesn't implement i18n yet | ||
> { | ||
ref?: React.RefObject<HTMLAnchorElement> | React.RefCallback<HTMLAnchorElement> | null | ||
} | ||
|
||
export interface UseNextLinkReturnProps extends Partial<JSX.IntrinsicElements['a']> { | ||
ref: React.RefCallback<HTMLAnchorElement>, | ||
onTouchStart: React.TouchEventHandler<HTMLAnchorElement>, | ||
onMouseEnter: React.MouseEventHandler<HTMLAnchorElement>, | ||
onClick: React.MouseEventHandler<HTMLAnchorElement>, | ||
href?: string | ||
} | ||
|
||
const isModifiedEvent = (event: React.MouseEvent<HTMLAnchorElement>) => { | ||
const eventTarget = event.currentTarget; | ||
const target = eventTarget.getAttribute('target'); | ||
return ( | ||
(target && target !== '_self') | ||
|| eventTarget.download | ||
|| event.metaKey | ||
|| event.ctrlKey | ||
|| event.shiftKey | ||
|| event.altKey // triggers resource download | ||
|| (event.nativeEvent && event.nativeEvent.which === 2) | ||
); | ||
}; | ||
|
||
// https://github.com/vercel/next.js/blob/39589ff35003ba73f92b7f7b349b3fdd3458819f/packages/next/src/client/components/router-reducer/router-reducer-types.ts#L148 | ||
const PREFETCH_APPROUTER_AUTO = 'auto'; | ||
const PREFETCH_APPROUTER_FULL = 'full'; | ||
|
||
const prefetch = ( | ||
router: ReturnType<typeof useRouter>, | ||
href: string, | ||
options: AppRouterPrefetchOptions | ||
) => { | ||
if (typeof window === 'undefined') { | ||
return; | ||
} | ||
|
||
// Prefetch the RSC if asked (only in the client) | ||
// We need to handle a prefetch error here since we may be | ||
// loading with priority which can reject but we don't | ||
// want to force navigation since this is only a prefetch | ||
Promise.resolve(router.prefetch(href, options)).catch((err) => { | ||
if (process.env.NODE_ENV !== 'production') { | ||
// rethrow to show invalid URL errors | ||
throw err; | ||
} | ||
}); | ||
}; | ||
|
||
/** @see https://foxact.skk.moe/use-next-link */ | ||
const useNextLink = ( | ||
hrefProp: string | UrlObject, | ||
{ | ||
prefetch: prefetchProp, | ||
ref, | ||
onClick, | ||
onMouseEnter, | ||
onTouchStart, | ||
scroll: routerScroll = true, | ||
replace = false, | ||
...restProps // Record<string, never> | ||
}: UseNextLinkOptions | ||
): [isPending: boolean, linkProps: UseNextLinkReturnProps] => { | ||
// Type guard to make sure there is no more props left in restProps | ||
if (process.env.NODE_ENV === 'development') { | ||
const _: Record<string, never> = restProps; | ||
} | ||
|
||
/** | ||
* The possible states for prefetch are: | ||
* - null: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport | ||
* - true: we will prefetch if the link is visible and prefetch the full page, not just partially | ||
* - false: we will not prefetch if in the viewport at all | ||
*/ | ||
const appPrefetchKind = prefetchProp == null ? PREFETCH_APPROUTER_AUTO : PREFETCH_APPROUTER_FULL; | ||
const prefetchEnabled = prefetchProp !== false; | ||
|
||
const router = useRouter(); | ||
|
||
const [isPending, startTransition] = useTransition(); | ||
|
||
const [setIntersectionRef, isVisible, resetVisible] = useIntersection({ | ||
rootMargin: '200px' | ||
}); | ||
|
||
const resolvedHref = useMemo(() => (typeof hrefProp === 'string' ? hrefProp : formatUrl(hrefProp)), [hrefProp]); | ||
const [previousResolvedHref, setPreviousResolvedHref] = useState<string>(resolvedHref); | ||
|
||
if (previousResolvedHref !== resolvedHref) { | ||
// It is safe to set the state during render, as long as it won't trigger an infinite render loop. | ||
// React will render the component with the current state, then throws away the render result | ||
// and immediately re-executes the component function with the updated state. | ||
setPreviousResolvedHref(resolvedHref); | ||
resetVisible(); | ||
} | ||
|
||
// Prefetch the URL if we haven't already and it's visible. | ||
useEffect(() => { | ||
// in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page. | ||
if (process.env.NODE_ENV !== 'production') { | ||
return; | ||
} | ||
|
||
// If we don't need to prefetch the URL, don't do prefetch. | ||
if (!isVisible || !prefetchEnabled) { | ||
return; | ||
} | ||
|
||
// Prefetch the URL. | ||
prefetch( | ||
router, | ||
resolvedHref, | ||
{ | ||
kind: appPrefetchKind as PrefetchKind | ||
} | ||
); | ||
}, [appPrefetchKind, isVisible, prefetchEnabled, resolvedHref, router]); | ||
|
||
const childProps: UseNextLinkReturnProps = { | ||
ref: useCallback<React.RefCallback<HTMLAnchorElement>>((el: HTMLAnchorElement | null) => { | ||
// track the element visibility | ||
setIntersectionRef(el); | ||
|
||
if (typeof ref === 'function') { | ||
ref(el); | ||
} else if (ref && el) { | ||
// We are acting on React behalf to assign the passed-in ref | ||
(ref as React.MutableRefObject<HTMLAnchorElement>).current = el; | ||
} | ||
}, [ref, setIntersectionRef]), | ||
onClick: useCallback((e) => { | ||
if (typeof onClick === 'function') { | ||
onClick(e); | ||
} | ||
if (e.defaultPrevented) { | ||
return; | ||
} | ||
|
||
const { nodeName } = e.currentTarget; | ||
// anchors inside an svg have a lowercase nodeName | ||
if ( | ||
nodeName.toUpperCase() === 'A' | ||
&& isModifiedEvent(e) | ||
) { | ||
// app-router supports external urls out of the box | ||
// ignore click for browser’s default behavior | ||
return; | ||
} | ||
|
||
e.preventDefault(); | ||
|
||
startTransition(() => { | ||
router[replace ? 'replace' : 'push'](resolvedHref, { scroll: routerScroll }); | ||
}); | ||
}, [onClick, replace, resolvedHref, router, routerScroll]), | ||
onMouseEnter: useCallback((e) => { | ||
if (typeof onMouseEnter === 'function') { | ||
onMouseEnter(e); | ||
} | ||
// Always disable prefetching during the development | ||
if (process.env.NODE_ENV === 'development') { | ||
return; | ||
} | ||
if (!prefetchEnabled) { | ||
return; | ||
} | ||
|
||
// Prefetch the URL. | ||
prefetch( | ||
router, | ||
resolvedHref, | ||
{ | ||
kind: appPrefetchKind as PrefetchKind | ||
} | ||
); | ||
}, [appPrefetchKind, onMouseEnter, prefetchEnabled, resolvedHref, router]), | ||
onTouchStart: useCallback((e) => { | ||
if (typeof onTouchStart === 'function') { | ||
onTouchStart(e); | ||
} | ||
// Always disable prefetching during the development | ||
if (process.env.NODE_ENV === 'development') { | ||
return; | ||
} | ||
if (!prefetchEnabled) { | ||
return; | ||
} | ||
|
||
// Prefetch the URL. | ||
prefetch( | ||
router, | ||
resolvedHref, | ||
{ | ||
kind: appPrefetchKind as PrefetchKind | ||
} | ||
); | ||
}, [appPrefetchKind, onTouchStart, prefetchEnabled, resolvedHref, router]), | ||
...restProps | ||
}; | ||
|
||
return [ | ||
isPending, | ||
childProps | ||
] as const; | ||
}; | ||
|
||
export const unstable_useNextLink = useNextLink; |