From e8d67e4003cc25d50fc20293b38d90ba5fc93059 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 21 Jan 2024 17:59:48 +0800 Subject: [PATCH] refactor: re-implement `useNextLink` --- src/use-next-link-props/index.ts | 174 ++++++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 24 deletions(-) diff --git a/src/use-next-link-props/index.ts b/src/use-next-link-props/index.ts index 4112ae0b..69e95c87 100644 --- a/src/use-next-link-props/index.ts +++ b/src/use-next-link-props/index.ts @@ -1,31 +1,157 @@ import 'client-only'; +import type { UrlObject } from 'url'; import type { LinkProps } from 'next/link'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { MouseEvent } from 'react'; -import { usePathname } from 'next/navigation'; +import { useCallback, useMemo, useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; -export interface ExtraProps { - isPending: boolean +import { formatUrl } from 'next/dist/shared/lib/router/utils/format-url'; +import { useIntersection } from '../use-intersection'; +import { noop } from '@/noop'; + +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 } -export const useNextLink = (props: LinkProps): LinkProps & ExtraProps => { - const pathname = usePathname(); - const [targetPathname, setTargetPathname] = useState(() => pathname); - useEffect(() => { - setTargetPathname(pathname); - }, [pathname]); - const onClickProp = props.onClick; - const onClick = useCallback((event: MouseEvent) => { - setTargetPathname(new URL(event.currentTarget.href).pathname); - return onClickProp?.(event); - }, [onClickProp]); - const isPending = targetPathname !== pathname; - return useMemo(() => { - return { - ...props, - onClick, - isPending - }; - }, [props, onClick, isPending]); +const isModifiedEvent = (event: React.MouseEvent) => { + const eventTarget = event.target as HTMLElement; + const target = eventTarget.getAttribute('target'); + return ( + (target && target !== '_self') + || event.metaKey + || event.ctrlKey + || event.shiftKey + || event.altKey // triggers resource download + || (event.nativeEvent && event.nativeEvent.which === 2) + ); +}; + +export const useNextLink = ( + hrefProp: string | UrlObject, + { + prefetch = true, + 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; + } + + 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(); + } + + const callbackRef: React.RefCallback = 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]); + + const childProps: UseNextLinkReturnProps = { + ref: callbackRef, + onClick(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 }); + }); + }, + onMouseEnter(e) { + if (typeof onMouseEnter === 'function') { + onMouseEnter(e); + } + // Always disable prefetching during the development + if (process.env.NODE_ENV === 'development') { + return; + } + if (!prefetch) { + return; + } + + // TODO-SUKKA: bring up prefetch + noop(e); + }, + onTouchStart(e) { + if (typeof onTouchStart === 'function') { + onTouchStart(e); + } + // Always disable prefetching during the development + if (process.env.NODE_ENV === 'development') { + return; + } + if (!prefetch) { + return; + } + + // TODO-SUKKA: bring up prefetch + noop(e); + }, + ...restProps + }; + + return [ + isPending, + childProps + ] as const; };