Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Segment Cache] Prioritize hovered links #74672

Open
wants to merge 4 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -621,5 +621,6 @@
"620": "A required parameter (%s) was not provided as %s received %s in getStaticPaths for %s",
"621": "Required root params (%s) were not provided in generateStaticParams for %s, please provide at least one value for each.",
"622": "A required root parameter (%s) was not provided in generateStaticParams for %s, please provide at least one value.",
"623": "Invalid quality prop (%s) on \\`next/image\\` does not match \\`images.qualities\\` configured in your \\`next.config.js\\`\\nSee more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities"
"623": "Invalid quality prop (%s) on \\`next/image\\` does not match \\`images.qualities\\` configured in your \\`next.config.js\\`\\nSee more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities",
"624": "Internal Next.js Error: createMutableActionQueue was called more than once"
}
274 changes: 220 additions & 54 deletions packages/next/src/client/app-dir/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import type { UrlObject } from 'url'
import { formatUrl } from '../../shared/lib/router/utils/format-url'
import { AppRouterContext } from '../../shared/lib/app-router-context.shared-runtime'
import type { AppRouterInstance } from '../../shared/lib/app-router-context.shared-runtime'
import type { PrefetchOptions } from '../../shared/lib/app-router-context.shared-runtime'
import { useIntersection } from '../use-intersection'
import { PrefetchKind } from '../components/router-reducer/router-reducer-types'
import { useMergedRef } from '../use-merged-ref'
import { isAbsoluteUrl } from '../../shared/lib/utils'
import { addBasePath } from '../add-base-path'
import { warnOnce } from '../../shared/lib/utils/warn-once'
import {
type PrefetchTask,
schedulePrefetchTask as scheduleSegmentPrefetchTask,
cancelPrefetchTask,
bumpPrefetchTask,
PrefetchPriority,
} from '../components/segment-cache/scheduler'
import { getCurrentAppRouterState } from '../../shared/lib/router/action-queue'
import { createCacheKey } from '../components/segment-cache/cache-key'
import { createPrefetchURL } from '../components/app-router'

type Url = string | UrlObject
type RequiredKeys<T> = {
Expand Down Expand Up @@ -112,19 +120,206 @@ export type LinkProps<RouteInferType = any> = InternalLinkProps
type LinkPropsRequired = RequiredKeys<LinkProps>
type LinkPropsOptional = OptionalKeys<Omit<InternalLinkProps, 'locale'>>

function prefetch(
router: AppRouterInstance,
type LinkInstance = {
router: AppRouterInstance
kind: PrefetchKind.AUTO | PrefetchKind.FULL
prefetchHref: string

isVisible: boolean
wasHoveredOrTouched: boolean

// The most recently initiated prefetch task. It may or may not have
// already completed. The same prefetch task object can be reused across
// multiple prefetches of the same link.
prefetchTask: PrefetchTask | null
}

// TODO: This is currently a WeakMap because it doesn't need to be enumerable,
// but eventually we'll want to be able to re-prefetch all the currently
// visible links, e.g. after a revalidation or refresh.
const links:
| WeakMap<HTMLAnchorElement | SVGAElement, LinkInstance>
| Map<Element, LinkInstance> =
typeof WeakMap === 'function' ? new WeakMap() : new Map()

// A single IntersectionObserver instance shared by all <Link> components.
const observer: IntersectionObserver | null =
typeof IntersectionObserver === 'function'
? new IntersectionObserver(handleIntersect, {
rootMargin: '200px',
})
: null

function mountLinkInstance(
element: HTMLAnchorElement | SVGAElement,
href: string,
options: PrefetchOptions
): void {
router: AppRouterInstance,
kind: PrefetchKind.AUTO | PrefetchKind.FULL
) {
let prefetchUrl: URL | null = null
try {
prefetchUrl = createPrefetchURL(href)
if (prefetchUrl === null) {
// We only track the link if it's prefetchable. For example, this excludes
// links to external URLs.
return
}
} catch {
// createPrefetchURL sometimes throws an error if an invalid URL is
// provided, though I'm not sure if it's actually necessary.
// TODO: Consider removing the throw from the inner function, or change it
// to reportError. Or maybe the error isn't even necessary for automatic
// prefetches, just navigations.
const reportErrorFn =
typeof reportError === 'function' ? reportError : console.error
reportErrorFn(
`Cannot prefetch '${href}' because it cannot be converted to a URL.`
)
return
}

const instance: LinkInstance = {
prefetchHref: prefetchUrl.href,
router,
kind,
isVisible: false,
wasHoveredOrTouched: false,
prefetchTask: null,
}
const existingInstance = links.get(element)
if (existingInstance !== undefined) {
// This shouldn't happen because each <Link> component should have its own
// anchor tag instance, but it's defensive coding to avoid a memory leak in
// case there's a logical error somewhere else.
unmountLinkInstance(element)
}
links.set(element, instance)
if (observer !== null) {
observer.observe(element)
}
}

export function unmountLinkInstance(element: HTMLAnchorElement | SVGAElement) {
const instance = links.get(element)
if (instance !== undefined) {
links.delete(element)
const prefetchTask = instance.prefetchTask
if (prefetchTask !== null) {
cancelPrefetchTask(prefetchTask)
}
}
if (observer !== null) {
observer.unobserve(element)
}
}

function handleIntersect(entries: Array<IntersectionObserverEntry>) {
for (const entry of entries) {
// Some extremely old browsers or polyfills don't reliably support
// isIntersecting so we check intersectionRatio instead. (Do we care? Not
// really. But whatever this is fine.)
const isVisible = entry.intersectionRatio > 0
onLinkVisibilityChanged(entry.target as HTMLAnchorElement, isVisible)
}
}

function onLinkVisibilityChanged(
element: HTMLAnchorElement | SVGAElement,
isVisible: boolean
) {
if (process.env.NODE_ENV !== 'production') {
// Prefetching on viewport is disabled in development for performance
// reasons, because it requires compiling the target page.
// TODO: Investigate re-enabling this.
return
}

const instance = links.get(element)
if (instance === undefined) {
return
}

instance.isVisible = isVisible
rescheduleLinkPrefetch(instance)
}

function onNavigationIntent(element: HTMLAnchorElement | SVGAElement) {
const instance = links.get(element)
if (instance === undefined) {
return
}
// Prefetch the link on hover/touchstart.
if (instance !== undefined) {
instance.wasHoveredOrTouched = true
rescheduleLinkPrefetch(instance)
}
}

function rescheduleLinkPrefetch(instance: LinkInstance) {
const existingPrefetchTask = instance.prefetchTask

if (!instance.isVisible) {
// Cancel any in-progress prefetch task. (If it already finished then this
// is a no-op.)
if (existingPrefetchTask !== null) {
cancelPrefetchTask(existingPrefetchTask)
}
// We don't need to reset the prefetchTask to null upon cancellation; an
// old task object can be rescheduled with bumpPrefetchTask. This is a
// micro-optimization but also makes the code simpler (don't need to
// worry about whether an old task object is stale).
return
}

if (!process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
// The old prefetch implementation does not have different priority levels.
// Just schedule a new prefetch task.
prefetchWithOldCacheImplementation(instance)
return
}

// In the Segment Cache implementation, we assign a higher priority level to
// links that were at one point hovered or touched. Since the queue is last-
// in-first-out, the highest priority Link is whichever one was hovered last.
//
// We also increase the relative priority of links whenever they re-enter the
// viewport, as if they were being scheduled for the first time.
const priority = instance.wasHoveredOrTouched
? PrefetchPriority.Intent
: PrefetchPriority.Default
if (existingPrefetchTask === null) {
// Initiate a prefetch task.
const appRouterState = getCurrentAppRouterState()
if (appRouterState !== null) {
const nextUrl = appRouterState.nextUrl
const treeAtTimeOfPrefetch = appRouterState.tree
const cacheKey = createCacheKey(instance.prefetchHref, nextUrl)
instance.prefetchTask = scheduleSegmentPrefetchTask(
cacheKey,
treeAtTimeOfPrefetch,
instance.kind === PrefetchKind.FULL,
priority
)
}
} else {
// We already have an old task object that we can reschedule. This is
// effectively the same as canceling the old task and creating a new one.
bumpPrefetchTask(existingPrefetchTask, priority)
}
}

function prefetchWithOldCacheImplementation(instance: LinkInstance) {
// This is the path used when the Segment Cache is not enabled.
if (typeof window === 'undefined') {
return
}

const doPrefetch = async () => {
// note that `appRouter.prefetch()` is currently sync,
// so we have to wrap this call in an async function to be able to catch() errors below.
return router.prefetch(href, options)
return instance.router.prefetch(instance.prefetchHref, {
kind: instance.kind,
})
}

// Prefetch the page if asked (only in the client)
Expand Down Expand Up @@ -394,9 +589,6 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
}
}, [hrefProp, asProp])

const previousHref = React.useRef<string>(href)
const previousAs = React.useRef<string>(as)

// This will return the first child, if multiple are provided it will throw an error
let child: any
if (legacyBehavior) {
Expand Down Expand Up @@ -443,47 +635,23 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
? child && typeof child === 'object' && child.ref
: forwardedRef

const [setIntersectionRef, isVisible, resetVisible] = useIntersection({
rootMargin: '200px',
})

const setIntersectionWithResetRef = React.useCallback(
(el: Element) => {
// Before the link getting observed, check if visible state need to be reset
if (previousAs.current !== as || previousHref.current !== href) {
resetVisible()
previousAs.current = as
previousHref.current = href
// Use a callback ref to attach an IntersectionObserver to the anchor tag on
// mount. In the future we will also use this to keep track of all the
// currently mounted <Link> instances, e.g. so we can re-prefetch them after
// a revalidation or refresh.
const observeLinkVisibilityOnMount = React.useCallback(
(element: HTMLAnchorElement | SVGAElement) => {
if (prefetchEnabled && router !== null) {
mountLinkInstance(element, href, router, appPrefetchKind)
}
return () => {
unmountLinkInstance(element)
}

setIntersectionRef(el)
},
[as, href, resetVisible, setIntersectionRef]
[prefetchEnabled, href, router, appPrefetchKind]
)

const setRef = useMergedRef(setIntersectionWithResetRef, childRef)

// Prefetch the URL if we haven't already and it's visible.
React.useEffect(() => {
// in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page.
if (process.env.NODE_ENV !== 'production') {
return
}

if (!router) {
return
}

// If we don't need to prefetch the URL, don't do prefetch.
if (!isVisible || !prefetchEnabled) {
return
}

// Prefetch the URL.
prefetch(router, href, {
kind: appPrefetchKind,
})
}, [as, href, isVisible, prefetchEnabled, router, appPrefetchKind])
const mergedRef = useMergedRef(observeLinkVisibilityOnMount, childRef)

const childProps: {
onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>
Expand All @@ -492,7 +660,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
href?: string
ref?: any
} = {
ref: setRef,
ref: mergedRef,
onClick(e) {
if (process.env.NODE_ENV !== 'production') {
if (!e) {
Expand Down Expand Up @@ -545,9 +713,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
return
}

prefetch(router, href, {
kind: appPrefetchKind,
})
onNavigationIntent(e.currentTarget as HTMLAnchorElement | SVGAElement)
},
onTouchStart: process.env.__NEXT_LINK_NO_TOUCH_START
? undefined
Expand All @@ -572,9 +738,9 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
return
}

prefetch(router, href, {
kind: appPrefetchKind,
})
onNavigationIntent(
e.currentTarget as HTMLAnchorElement | SVGAElement
)
},
}

Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,12 @@ function Router({
? // Unlike the old implementation, the Segment Cache doesn't store its
// data in the router reducer state; it writes into a global mutable
// cache. So we don't need to dispatch an action.
(href) =>
(href, options) =>
prefetchWithSegmentCache(
href,
actionQueue.state.nextUrl,
actionQueue.state.tree
actionQueue.state.tree,
options?.kind === PrefetchKind.FULL
)
: (href, options) => {
// Use the old prefetch implementation.
Expand Down
Loading
Loading