Repository: vercel/next.js
Pull request: Reserve bandwidth for most recently hovered link 

Based on:

- #77880 

---

The prefetch scheduler limits the number of concurrent prefetch requests, to avoid saturating browser's internal network queue. As a starting point, I had previously hardcoded this to 3 — intentionally stingy, to leave room for additional optimizations.

In this PR, I allow the most recently hovered link to exceed the default limit. This increased limit only applies to the *most recently* hovered link; if a different link is hovered, the previous one is bumped back down to the default.

The principle is that when hovering over a new link, we should have enough bandwidth to start prefetching its data. (In practice, because we don't cancel in-progress requests, it's still possible that we'll fill up the queue, but this makes it less likely.)

As part of this change, I changed the implementation of the internal Intent priority level. Intent used to assigned to any link that was ever hovered over; it would never get bumped back down to Default, even on mouseexit. This was intentional at the time, but given this new optimizaiton, it makes more sense to downgrade previously hovered links to free up the bandwidth.

Changed files:
filename: packages/next/src/client/components/links.ts
patch: @@ -25,7 +25,6 @@ type LinkOrFormInstanceShared = {
   kind: PrefetchKind.AUTO | PrefetchKind.FULL
 
   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
@@ -152,7 +151,6 @@ export function mountLinkInstance(
         router,
         kind,
         isVisible: false,
-        wasHoveredOrTouched: false,
         prefetchTask: null,
         prefetchHref: prefetchURL.href,
         setOptimisticLinkStatus,
@@ -169,7 +167,6 @@ export function mountLinkInstance(
     router,
     kind,
     isVisible: false,
-    wasHoveredOrTouched: false,
     prefetchTask: null,
     prefetchHref: null,
     setOptimisticLinkStatus,
@@ -195,7 +192,6 @@ export function mountFormInstance(
     router,
     kind,
     isVisible: false,
-    wasHoveredOrTouched: false,
     prefetchTask: null,
     prefetchHref: prefetchURL.href,
     setOptimisticLinkStatus: null,
@@ -247,7 +243,7 @@ export function onLinkVisibilityChanged(element: Element, isVisible: boolean) {
   } else {
     prefetchableAndVisible.delete(instance)
   }
-  rescheduleLinkPrefetch(instance)
+  rescheduleLinkPrefetch(instance, PrefetchPriority.Default)
 }
 
 export function onNavigationIntent(
@@ -260,19 +256,21 @@ export function onNavigationIntent(
   }
   // Prefetch the link on hover/touchstart.
   if (instance !== undefined) {
-    instance.wasHoveredOrTouched = true
     if (
       process.env.__NEXT_DYNAMIC_ON_HOVER &&
       unstable_upgradeToDynamicPrefetch
     ) {
       // Switch to a full, dynamic prefetch
       instance.kind = PrefetchKind.FULL
     }
-    rescheduleLinkPrefetch(instance)
+    rescheduleLinkPrefetch(instance, PrefetchPriority.Intent)
   }
 }
 
-function rescheduleLinkPrefetch(instance: PrefetchableInstance) {
+function rescheduleLinkPrefetch(
+  instance: PrefetchableInstance,
+  priority: PrefetchPriority.Default | PrefetchPriority.Intent
+) {
   const existingPrefetchTask = instance.prefetchTask
 
   if (!instance.isVisible) {
@@ -295,15 +293,6 @@ function rescheduleLinkPrefetch(instance: PrefetchableInstance) {
     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
   const appRouterState = getCurrentAppRouterState()
   if (appRouterState !== null) {
     const treeAtTimeOfPrefetch = appRouterState.tree
@@ -355,14 +344,11 @@ export function pingVisibleLinks(
       cancelPrefetchTask(task)
     }
     const cacheKey = createCacheKey(instance.prefetchHref, nextUrl)
-    const priority = instance.wasHoveredOrTouched
-      ? PrefetchPriority.Intent
-      : PrefetchPriority.Default
     instance.prefetchTask = scheduleSegmentPrefetchTask(
       cacheKey,
       tree,
       instance.kind === PrefetchKind.FULL,
-      priority,
+      PrefetchPriority.Default,
       null
     )
   }

filename: packages/next/src/client/components/segment-cache-impl/scheduler.ts
patch: @@ -169,14 +169,16 @@ export type PrefetchSubtaskResult<T> = {
 
 const taskHeap: Array<PrefetchTask> = []
 
-// This is intentionally low so that when a navigation happens, the browser's
-// internal network queue is not already saturated with prefetch requests.
-const MAX_CONCURRENT_PREFETCH_REQUESTS = 3
 let inProgressRequests = 0
 
 let sortIdCounter = 0
 let didScheduleMicrotask = false
 
+// The most recently hovered (or touched, etc) link, i.e. the most recent task
+// scheduled at Intent priority. There's only ever a single task at Intent
+// priority at a time. We reserve special network bandwidth for this task only.
+let mostRecentlyHoveredLink: PrefetchTask | null = null
+
 /**
  * Initiates a prefetch task for the given URL. If a prefetch for the same URL
  * is already in progress, this will bump it to the top of the queue.
@@ -210,6 +212,9 @@ export function schedulePrefetchTask(
     onInvalidate,
     _heapIndex: -1,
   }
+
+  trackMostRecentlyHoveredLink(task)
+
   heapPush(taskHeap, task)
 
   // Schedule an async task to process the queue.
@@ -254,11 +259,16 @@ export function reschedulePrefetchTask(
   // Assign a new sort ID to move it ahead of all other tasks at the same
   // priority level. (Higher sort IDs are processed first.)
   task.sortId = sortIdCounter++
-  task.priority = priority
+  task.priority =
+    // If this task is the most recently hovered link, maintain its
+    // Intent priority, even if the rescheduled priority is lower.
+    task === mostRecentlyHoveredLink ? PrefetchPriority.Intent : priority
 
   task.treeAtTimeOfPrefetch = treeAtTimeOfPrefetch
   task.includeDynamicData = includeDynamicData
 
+  trackMostRecentlyHoveredLink(task)
+
   if (task._heapIndex !== -1) {
     // The task is already in the queue.
     heapResift(taskHeap, task)
@@ -286,11 +296,27 @@ export function isPrefetchTaskDirty(
   )
 }
 
+function trackMostRecentlyHoveredLink(task: PrefetchTask) {
+  // Track the mostly recently hovered link, i.e. the most recently scheduled
+  // task at Intent priority. There must only be one such task at a time.
+  if (
+    task.priority === PrefetchPriority.Intent &&
+    task !== mostRecentlyHoveredLink
+  ) {
+    if (mostRecentlyHoveredLink !== null) {
+      // Bump the previously hovered link's priority down to Default.
+      if (mostRecentlyHoveredLink.priority !== PrefetchPriority.Background) {
+        mostRecentlyHoveredLink.priority = PrefetchPriority.Default
+        heapResift(taskHeap, mostRecentlyHoveredLink)
+      }
+    }
+    mostRecentlyHoveredLink = task
+  }
+}
+
 function ensureWorkIsScheduled() {
-  if (didScheduleMicrotask || !hasNetworkBandwidth()) {
-    // Either we already scheduled a task to process the queue, or there are
-    // too many concurrent requests in progress. In the latter case, the
-    // queue will resume processing once more bandwidth is available.
+  if (didScheduleMicrotask) {
+    // Already scheduled a task to process the queue
     return
   }
   didScheduleMicrotask = true
@@ -303,12 +329,28 @@ function ensureWorkIsScheduled() {
  * cooperative limit — prefetch tasks should check this before issuing
  * new requests.
  */
-function hasNetworkBandwidth(): boolean {
+function hasNetworkBandwidth(task: PrefetchTask): boolean {
   // TODO: Also check if there's an in-progress navigation. We should never
   // add prefetch requests to the network queue if an actual navigation is
   // taking place, to ensure there's sufficient bandwidth for render-blocking
   // data and resources.
-  return inProgressRequests < MAX_CONCURRENT_PREFETCH_REQUESTS
+
+  // TODO: Consider reserving some amount of bandwidth for static prefetches.
+
+  if (task.priority === PrefetchPriority.Intent) {
+    // The most recently hovered link is allowed to exceed the default limit.
+    //
+    // The goal is to always have enough bandwidth to start a new prefetch
+    // request when hovering over a link.
+    //
+    // However, because we don't abort in-progress requests, it's still possible
+    // we'll run out of bandwidth. When links are hovered in quick succession,
+    // there could be multiple hover requests running simultaneously.
+    return inProgressRequests < 12
+  }
+
+  // The default limit is lower than the limit for a hovered link.
+  return inProgressRequests < 4
 }
 
 function spawnPrefetchSubtask<T>(
@@ -375,7 +417,7 @@ function processQueueInMicrotask() {
 
   // Process the task queue until we run out of network bandwidth.
   let task = heapPeek(taskHeap)
-  while (task !== null && hasNetworkBandwidth()) {
+  while (task !== null && hasNetworkBandwidth(task)) {
     task.cacheVersion = getCurrentCacheVersion()
 
     const route = readOrCreateRouteCacheEntry(now, task)
@@ -496,7 +538,7 @@ function pingRootRouteTree(
         return PrefetchTaskExitStatus.Done
       }
       // Recursively fill in the segment tree.
-      if (!hasNetworkBandwidth()) {
+      if (!hasNetworkBandwidth(task)) {
         // Stop prefetching segments until there's more bandwidth.
         return PrefetchTaskExitStatus.InProgress
       }
@@ -565,7 +607,7 @@ function pingPPRRouteTree(
   const segment = readOrCreateSegmentCacheEntry(now, task, route, tree.key)
   pingPerSegment(now, task, route, segment, task.key, tree.key)
   if (tree.slots !== null) {
-    if (!hasNetworkBandwidth()) {
+    if (!hasNetworkBandwidth(task)) {
       // Stop prefetching segments until there's more bandwidth.
       return PrefetchTaskExitStatus.InProgress
     }

filename: packages/next/src/client/components/segment-cache.ts
patch: @@ -118,9 +118,10 @@ export const enum NavigationResultTag {
  */
 export const enum PrefetchPriority {
   /**
-   * Assigned to any visible link that was hovered/touched at some point. This
-   * is not removed on mouse exit, because a link that was momentarily
-   * hovered is more likely to to be interacted with than one that was not.
+   * Assigned to the most recently hovered/touched link. Special network
+   * bandwidth is reserved for this task only. There's only ever one Intent-
+   * priority task at a time; when a new Intent task is scheduled, the previous
+   * one is bumped down to Default.
    */
   Intent = 2,
   /**

filename: test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts
patch: @@ -94,58 +94,67 @@ describe('segment cache prefetch scheduling', () => {
     ])
   })
 
-  it(
-    'even on mouseexit, any link that was previously hovered is prioritized ' +
-      'over links that were never hovered at all',
-    async () => {
-      let act: ReturnType<typeof createRouterAct>
-      const browser = await next.browser('/cancellation', {
-        beforePageLoad(p: Playwright.Page) {
-          act = createRouterAct(p)
-        },
-      })
-
-      const checkbox = await browser.elementByCss('input[type="checkbox"]')
-      await act(
-        async () => {
-          // Reveal the links to start prefetching, but block the responses from
-          // reaching the client. This will initiate prefetches for the route
-          // trees, but it won't start prefetching any segment data yet until the
-          // trees have loaded.
-          await act(async () => {
-            await checkbox.click()
-          }, 'block')
-
-          // Hover over a link to increase its relative priority.
-          const link2 = await browser.elementByCss('a[href="/cancellation/2"]')
-          await link2.hover()
+  it('reserve special bandwidth for the most recently hovered link', async () => {
+    let act: ReturnType<typeof createRouterAct>
+    const browser = await next.browser('/cancellation', {
+      beforePageLoad(p: Playwright.Page) {
+        act = createRouterAct(p)
+      },
+    })
 
-          // Hover over a different link to increase its relative priority.
-          const link5 = await browser.elementByCss('a[href="/cancellation/5"]')
-          await link5.hover()
+    const checkbox = await browser.elementByCss('input[type="checkbox"]')
+    await act(async () => {
+      // Reveal the links to start prefetching, but block the responses from
+      // reaching the client. This will initiate prefetches for the route
+      // trees, but it won't start prefetching any segment data yet until the
+      // trees have loaded.
+      await act(async () => {
+        await checkbox.click()
+      }, 'block')
+
+      // At this point, the network queue is already at max capacity, so
+      // revealing more links won't initiate any new requests.
+      await act(async () => {
+        const showMoreLinksButton = await browser.elementById('show-more-links')
+        await showMoreLinksButton.click()
+      }, 'no-requests')
+
+      // However, when hovering over a link, the prefetch task is allowed to
+      // exceed the default limit. So hovering over a link does initiate a
+      // new request.
+      await act(async () => {
+        // Hover over a link. This will initiate a request for the route tree,
+        // but before we're able to fetch the segments, we'll have already
+        // hovered over a different link.
+        const link2 = await browser.elementByCss('a[href="/cancellation/2"]')
+        await link2.hover()
 
-          // Click on the "Show More Links" button to reveal additional links.
-          // Even though these links are newer than the ones we hovered over,
-          // the hovered links should be prefetched first.
-          const showMoreLinksButton =
-            await browser.elementById('show-more-links')
-          await showMoreLinksButton.click()
-        },
-        // Assert that the segment data is prefetched in the expected order.
-        [
-          // The last link we hovered over should be the first to prefetch.
-          { includes: 'Content of page 5' },
-          // The second-to-last link we hovered over should come next.
-          { includes: 'Content of page 2' },
-          // Then assert on one of the links that were revealed when we click
-          // the "Show More Links" button
-          { includes: 'Content of page 10' },
-          // Then assert on one of the other links that were revealed originally
-          { includes: 'Content of page 4' },
-        ]
-      )
-    }
-  )
+        // Immediately hover over a different link.
+        const link3 = await browser.elementByCss('a[href="/cancellation/3"]')
+        await link3.hover()
+      }, [
+        // The most recently hovered link is allowed to finish loading its
+        // segment data.
+        { includes: 'Content of page 3' },
+        // The previously hovered link was downgraded to the default priority,
+        // so it should still be blocked.
+        { includes: 'Content of page 2', block: 'reject' },
+      ])
+    }, [
+      // Assert that everything else proceeds in the expected order. We don't
+      // need to assert on every single prefetch response; I picked just a few
+      // of them.
+
+      // The previously hovered link is the next to finish loading, because
+      // even though it was downgraded to the default priority, it was still
+      // moved ahead of the other default tasks.
+      { includes: 'Content of page 2' },
+      // Next are the links that were revealed by the "Show More" button.
+      { includes: 'Content of page 8' },
+      // Then the rest.
+      { includes: 'Content of page 5' },
+    ])
+  })
 
   it(
     'cancels a viewport-initiated prefetch if the link leaves the viewport ' +