diff --git a/docs/src/pages/_meta.json b/docs/src/pages/_meta.json index a081b417..e879fcad 100644 --- a/docs/src/pages/_meta.json +++ b/docs/src/pages/_meta.json @@ -75,6 +75,7 @@ "type": "separator", "title": "Extensions" }, + "use-next-link": {}, "use-next-pathname": {}, "use-react-router-enable-concurrent-navigation": {}, "use-react-router-is-match": {} diff --git a/docs/src/pages/use-next-link.mdx b/docs/src/pages/use-next-link.mdx new file mode 100644 index 00000000..7f040e39 --- /dev/null +++ b/docs/src/pages/use-next-link.mdx @@ -0,0 +1,49 @@ +--- +title: useNextLink (Next.js App Router) +--- + +# useNextLink (Next.js App Router) + +import ExportMetaInfo from '../components/export-meta-info'; + + + +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' + + +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. + + +```tsx +'use client'; + +import { useRef } from 'react'; +import { unstable_useNextLink as useNextLink } from 'foxact/use-next-link'; + +export default function Page() { + const ref = useRef(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 + ref + } + ); + + return ( +
+ {isPending &&
Navigating...
} + {/** You can only pass non-"next/link" prop directly to the
*/} + {/** All "next/link" prop and "ref" should be passed to useNextLink() */} + + About + +
+ ); +} +``` diff --git a/src/use-next-link/index.ts b/src/use-next-link/index.ts new file mode 100644 index 00000000..81a3bbb5 --- /dev/null +++ b/src/use-next-link/index.ts @@ -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 { + ref?: React.RefObject | React.RefCallback | null +} + +export interface UseNextLinkReturnProps extends Partial { + ref: React.RefCallback, + onTouchStart: React.TouchEventHandler, + onMouseEnter: React.MouseEventHandler, + onClick: React.MouseEventHandler, + href?: string +} + +const isModifiedEvent = (event: React.MouseEvent) => { + 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, + 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 + }: 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 = 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(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>((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).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;