rsnext/packages/next/client/link.tsx
Yuhei Yasuda 052a9d2353
Don’t prevent the browser’s default behavior for Alt key (#16003)
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                |
2020-08-10 20:32:47 +00:00

277 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 browsers 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