Skip to content

Commit

Permalink
Remove unnecessary re-render on <Link> prefetch
Browse files Browse the repository at this point in the history
The Link component initiates a prefetch whenever it enters the viewport,
but currently the implementation works by setting an `isVisible` state
to true, rerendering the component, and calling `router.prefetch`
inside a `useEffect` hook. This extra render is not necessary — we can
initiate the prefetch directly inside the IntersectionObserver's
event handler.

The bulk of the changes in this commit involve removing the use of the
`useIntersection` abstraction and inlining the IntersectionObserver
into the Link module. The removal of this indirection will make it
easier to add more sophisticated scheduling improvements, like canceling
the prefetch on viewport exit, and increasing the priority of the
prefetch during hover.

This affects App Router only, not Pages Router.
  • Loading branch information
acdlite committed Jan 8, 2025
1 parent a014cca commit dffd2f7
Show file tree
Hide file tree
Showing 10 changed files with 421 additions and 77 deletions.
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"
}
241 changes: 187 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,19 @@ 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,
bumpPrefetchTask,
} 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 +118,175 @@ 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

// 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
) {
const prefetchUrl = createPrefetchURL(href)
if (prefetchUrl === null) {
// We only track the link if it's prefetchable. For example, this excludes
// links to external URLs.
return
}
const instance: LinkInstance = {
prefetchHref: prefetchUrl.href,
router,
kind,
isVisible: 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) {
// TODO: In the Segment Cache implementation, cancel the prefetch task
// when the link is unmounted.
}
}
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) {
rescheduleLinkPrefetch(instance)
}
}

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

if (!instance.isVisible) {
// TODO: In the Segment Cache implementation, cancel the prefetch task when
// the link leaves the viewport.
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 increase the relative priority of
// links whenever they re-enter the viewport, as if they were being scheduled
// for the first time.
// TODO: Prioritize links that are hovered.
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
)
}
} 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)
}
}

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 +556,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 +602,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 +627,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 +680,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 +705,9 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
return
}

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

Expand Down
7 changes: 4 additions & 3 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ export function createPrefetchURL(href: string): URL | null {
try {
url = new URL(addBasePath(href), window.location.href)
} catch (_) {
// TODO: Does this need to throw or can we just console.error instead? Does
// anyone rely on this throwing? (Seems unlikely.)
throw new Error(
const reportErrorFn =
typeof reportError === 'function' ? reportError : console.error
reportErrorFn(
`Cannot prefetch '${href}' because it cannot be converted to a URL.`
)
return null
}

// Don't prefetch during development (improves compilation performance)
Expand Down
Loading

0 comments on commit dffd2f7

Please sign in to comment.