import React, { Children } from 'react' import { UrlObject } from 'url' import { PrefetchOptions, NextRouter, isLocalURL, } from '../next-server/lib/router/router' import { execOnce } from '../next-server/lib/utils' import { useRouter } from './router' import { addBasePath, resolveHref } from '../next-server/lib/router/router' type Url = string | UrlObject export type LinkProps = { href: Url as?: Url replace?: boolean scroll?: boolean shallow?: boolean passHref?: boolean prefetch?: boolean } let cachedObserver: IntersectionObserver const listeners = new Map void>() const IntersectionObserver = typeof window !== 'undefined' ? window.IntersectionObserver : null const prefetched: { [cacheKey: string]: boolean } = {} function getObserver(): IntersectionObserver | undefined { // Return shared instance of IntersectionObserver if already created if (cachedObserver) { return cachedObserver } // Only create shared IntersectionObserver if supported in browser if (!IntersectionObserver) { return undefined } return (cachedObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!listeners.has(entry.target)) { return } const cb = listeners.get(entry.target)! if (entry.isIntersecting || entry.intersectionRatio > 0) { cachedObserver.unobserve(entry.target) listeners.delete(entry.target) cb() } }) }, { rootMargin: '200px' } )) } const listenToIntersections = (el: Element, cb: () => void) => { const observer = getObserver() if (!observer) { return () => {} } observer.observe(el) listeners.set(el, cb) return () => { try { observer.unobserve(el) } catch (err) { console.error(err) } listeners.delete(el) } } function prefetch( router: NextRouter, href: string, as: string, options?: PrefetchOptions ): void { if (typeof window === 'undefined') return if (!isLocalURL(href)) return // Prefetch the JSON page 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 router.prefetch(href, as, options).catch((err) => { if (process.env.NODE_ENV !== 'production') { // rethrow to show invalid URL errors throw err } }) // Join on an invalid URI character prefetched[href + '%' + as] = true } function isNewTabRequest(event: React.MouseEvent) { const { target } = event.currentTarget as HTMLAnchorElement return ( (target && target !== '_self') || event.metaKey || event.ctrlKey || event.shiftKey || (event.nativeEvent && event.nativeEvent.which === 2) ) } function linkClicked( e: React.MouseEvent, router: NextRouter, href: string, as: string, replace?: boolean, shallow?: boolean, scroll?: boolean ): void { const { nodeName } = e.currentTarget if (nodeName === 'A' && (isNewTabRequest(e) || !isLocalURL(href))) { // ignore click for new tab / new window behavior return } e.preventDefault() // avoid scroll for urls with anchor refs if (scroll == null) { scroll = as.indexOf('#') < 0 } // replace state instead of push if prop is present router[replace ? 'replace' : 'push'](href, as, { shallow }).then( (success: boolean) => { if (!success) return if (scroll) { window.scrollTo(0, 0) document.body.focus() } } ) } function Link(props: React.PropsWithChildren) { if (process.env.NODE_ENV !== 'production') { // This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes // eslint-disable-next-line react-hooks/rules-of-hooks const hasWarned = React.useRef(false) if (props.prefetch && !hasWarned.current) { hasWarned.current = true console.warn( 'Next.js auto-prefetches automatically based on viewport. The prefetch attribute is no longer needed. More: https://err.sh/vercel/next.js/prefetch-true-deprecated' ) } } const p = props.prefetch !== false const [childElm, setChildElm] = React.useState() const router = useRouter() const pathname = (router && router.pathname) || '/' const { href, as } = React.useMemo(() => { const resolvedHref = resolveHref(pathname, props.href) return { href: resolvedHref, as: props.as ? resolveHref(pathname, props.as) : resolvedHref, } }, [pathname, props.href, props.as]) React.useEffect(() => { if ( p && IntersectionObserver && childElm && childElm.tagName && isLocalURL(href) ) { // Join on an invalid URI character const isPrefetched = prefetched[href + '%' + as] if (!isPrefetched) { return listenToIntersections(childElm, () => { prefetch(router, href, as) }) } } }, [p, childElm, href, as, router]) let { children, replace, shallow, scroll } = props // Deprecated. Warning shown by propType check. If the children provided is a string (example) we wrap it in an tag if (typeof children === 'string') { children = {children} } // This will return the first child, if multiple are provided it will throw an error const child: any = Children.only(children) const childProps: { onMouseEnter?: React.MouseEventHandler onClick: React.MouseEventHandler href?: string ref?: any } = { ref: (el: any) => { if (el) setChildElm(el) if (child && typeof child === 'object' && child.ref) { if (typeof child.ref === 'function') child.ref(el) else if (typeof child.ref === 'object') { child.ref.current = el } } }, onClick: (e: React.MouseEvent) => { if (child.props && typeof child.props.onClick === 'function') { child.props.onClick(e) } if (!e.defaultPrevented) { linkClicked(e, router, href, as, replace, shallow, scroll) } }, } if (p) { childProps.onMouseEnter = (e: React.MouseEvent) => { if (!isLocalURL(href)) return if (child.props && typeof child.props.onMouseEnter === 'function') { child.props.onMouseEnter(e) } prefetch(router, href, as, { priority: true }) } } // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is // defined, we specify the current 'href', so that repetition is not needed by the user if (props.passHref || (child.type === 'a' && !('href' in child.props))) { childProps.href = addBasePath(as) } return React.cloneElement(child, childProps) } if (process.env.NODE_ENV === 'development') { const warn = execOnce(console.error) // This module gets removed by webpack.IgnorePlugin const PropTypes = require('prop-types') const exact = require('prop-types-exact') // @ts-ignore the property is supported, when declaring it on the class it outputs an extra bit of code which is not needed. Link.propTypes = exact({ href: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, as: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), prefetch: PropTypes.bool, replace: PropTypes.bool, shallow: PropTypes.bool, passHref: PropTypes.bool, scroll: PropTypes.bool, children: PropTypes.oneOfType([ PropTypes.element, (props: any, propName: string) => { const value = props[propName] if (typeof value === 'string') { warn( `Warning: You're using a string directly inside . This usage has been deprecated. Please add an tag as child of ` ) } return null }, ]).isRequired, }) } export default Link