rsnext/packages/next/client/link.tsx

277 lines
7.7 KiB
TypeScript
Raw Normal View History

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<Element, () => 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(
2020-05-18 21:24:37 +02:00
(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()
}
2016-10-06 09:07:41 +02:00
}
)
}
2016-10-06 09:07:41 +02:00
function Link(props: React.PropsWithChildren<LinkProps>) {
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'
)
2016-10-06 09:07:41 +02:00
}
}
const p = props.prefetch !== false
2016-10-06 09:07:41 +02:00
const [childElm, setChildElm] = React.useState<Element>()
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])
2016-10-06 09:07:41 +02:00
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 (<Link>example</Link>) we wrap it in an <a> tag
if (typeof children === 'string') {
children = <a>{children}</a>
}
2016-10-06 09:07:41 +02:00
// 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)
}
},
2017-02-15 15:01:03 +01:00
}
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 })
}
}
2016-10-06 09:07:41 +02:00
// If child is an <a> 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)
}
2016-10-06 09:07:41 +02:00
return React.cloneElement(child, childProps)
2016-10-06 09:07:41 +02:00
}
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 <Link>. This usage has been deprecated. Please add an <a> tag as child of <Link>`
)
}
return null
},
]).isRequired,
})
}
export default Link