052a9d2353
In most browsers, clicking links with the Alt key has a special behavior, for example, Chrome downloads the target resource. As with other modifier keys, the router should stop the original navigation to avoid preventing the browser’s default behavior. When users click a link while holding the Alt key together, the browsers behave as follows. Windows 10: | Browser | Behavior | |:-----------|:--------------------------------------------| | Chrome 84 | Download the target resource | | Firefox 79 | Prevent navigation and therefore do nothing | | Edge 84 | Download the target resource | | IE 11 | No impact | macOS Catalina: | Browser | Behavior | |:-----------|:--------------------------------------------| | Chrome 84 | Download the target resource | | Firefox 79 | Prevent navigation and therefore do nothing | | Safari 13 | Download the target resource |
277 lines
7.8 KiB
TypeScript
277 lines
7.8 KiB
TypeScript
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(
|
||
(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 isModifiedEvent(event: React.MouseEvent) {
|
||
const { target } = event.currentTarget as HTMLAnchorElement
|
||
return (
|
||
(target && target !== '_self') ||
|
||
event.metaKey ||
|
||
event.ctrlKey ||
|
||
event.shiftKey ||
|
||
event.altKey || // triggers resource download
|
||
(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' && (isModifiedEvent(e) || !isLocalURL(href))) {
|
||
// ignore click for browser’s default 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<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'
|
||
)
|
||
}
|
||
}
|
||
const p = props.prefetch !== false
|
||
|
||
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])
|
||
|
||
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>
|
||
}
|
||
|
||
// 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 <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)
|
||
}
|
||
|
||
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 <Link>. This usage has been deprecated. Please add an <a> tag as child of <Link>`
|
||
)
|
||
}
|
||
|
||
return null
|
||
},
|
||
]).isRequired,
|
||
})
|
||
}
|
||
|
||
export default Link
|