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] Cancel prefetch on viewport exit #74671

Open
wants to merge 3 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"
}
264 changes: 210 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,20 @@ 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,
} 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 +119,197 @@ 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
) {
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,
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) {
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 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 +579,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 +625,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 +650,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 +703,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 +728,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
12 changes: 10 additions & 2 deletions packages/next/src/client/components/segment-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export type RouteCacheEntry =

export const enum FetchStrategy {
PPR,
Full,
LoadingBoundary,
}

Expand Down Expand Up @@ -1004,24 +1005,31 @@ export async function fetchSegmentOnCacheMiss(
}
}

export async function fetchSegmentPrefetchesForPPRDisabledRoute(
export async function fetchSegmentPrefetchesUsingDynamicRequest(
task: PrefetchTask,
route: FulfilledRouteCacheEntry,
fetchStrategy: FetchStrategy,
dynamicRequestTree: FlightRouterState,
spawnedEntries: Map<string, PendingSegmentCacheEntry>
): Promise<PrefetchSubtaskResult<null> | null> {
const href = task.key.href
const nextUrl = task.key.nextUrl
const headers: RequestHeaders = {
[RSC_HEADER]: '1',
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
[NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent(
JSON.stringify(dynamicRequestTree)
),
}
if (nextUrl !== null) {
headers[NEXT_URL] = nextUrl
}
// Only set the prefetch header if we're not doing a "full" prefetch. We
// omit the prefetch header from a full prefetch because it's essentially
// just a navigation request that happens ahead of time — it should include
// all the same data in the response.
if (fetchStrategy !== FetchStrategy.Full) {
headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'
}
try {
const response = await fetchPrefetchResponse(href, headers)
if (!response || !response.ok || !response.body) {
Expand Down
Loading
Loading