Files
Andrew ClarkandGitHub c099530eb7 Prefetch links nearest the top of the document first (#95393)
### Why

Most viewport prefetches are scheduled via a shared
`IntersectionObserver`. When several links enter the viewport at once
(e.g. on initial load), the observer reports them in document order and
we scheduled them in that order. Because the prefetch scheduler gives
the highest priority to the *most recently scheduled* task, this meant
the link **lowest** in the document ended up with the highest priority —
the opposite of a sensible default.

### What

Iterate each observer batch in reverse, so the link nearest the **top**
of the document is scheduled last and therefore prioritized. The topmost
link isn't guaranteed to be the most important, but as a default
heuristic it's more reasonable than favoring whichever link happens to
be lowest in the document.

Also updates the two hover tests in `prefetch-scheduling`, whose
expected ordering was coupled to the old bottom-first default (the set
of route trees prefetched within the concurrency limit flips from pages
7,6,5,4 to 1,2,3,4). The behaviors they assert are unchanged, and a new
test covers the top-of-document prioritization.

---

Supersedes #94902, which was opened from a fork. The "Test new and
changed tests when deployed" jobs cannot pass on fork PRs — `vercel
link` fails with "No existing credentials found" because
`secrets.VERCEL_ADAPTER_TEST_TOKEN` is not available to `pull_request`
runs from forks. This PR is the same change (rebased onto latest canary)
on an upstream branch so the deploy jobs get credentials.

<!-- NEXT_JS_LLM_PR -->
2026-07-02 08:17:05 -04:00

294 lines
12 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
describe('segment cache prefetch scheduling', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
test('prefetching is disabled', () => {})
return
}
it('increases the priority of a viewport-initiated prefetch on hover', 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 near the bottom of the document to increase its
// relative priority. By default the scheduler prioritizes the links
// nearest the top, so this link's route tree has not been prefetched
// yet; hovering bumps it ahead of the default tasks.
const link6 = await browser.elementByCss('a[href="/cancellation/6"]')
await link6.hover()
// Hover over a different link to increase its relative priority. This
// one is near the top of the document, so its route tree is already
// in-flight.
const link2 = await browser.elementByCss('a[href="/cancellation/2"]')
await link2.hover()
},
// 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 2' },
// The second-to-last link we hovered over should come next, ahead of
// the other default tasks.
{ includes: 'Content of page 6' },
// Then all the other links come after that. (We don't need to assert
// on every single prefetch response. I picked one of them arbitrarily.)
{ includes: 'Content of page 4' },
]
)
})
it('prioritizes links nearest the top of the document when many enter the viewport at once', 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 all the links at once. They all enter the viewport in the same
// IntersectionObserver callback, which reports them in document order
// (page 1 through 7).
await checkbox.click()
},
// The prefetch scheduler gives the highest priority to the most recently
// scheduled task. Rather than letting the link lowest in the document win
// (which is what happens if we schedule the observer entries in the order
// they're reported), we schedule them in reverse, so the link nearest the
// top of the document is scheduled last and therefore prefetched first.
[
{ includes: 'Content of page 1' },
{ includes: 'Content of page 2' },
{ includes: 'Content of page 3' },
]
)
})
// TODO: This test no longer works as-written because the metadata is now
// fetched separately from the route tree. Need to rewrite to assert on
// something that appears in the route tree and is relatively stable, like a
// static route name.
it.skip('prioritizes prefetching the route trees before the segments', 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
await checkbox.click()
}, [
// Assert on the order that the prefetches requests are
// initiated. We don't need to assert on every single prefetch response;
// this will only check the order of the ones that we've listed.
//
// To detect when the route tree is prefetched, we check for a string
// that is known to be present in the target page's viewport config
// (which is included in the route tree response). In this test app, the
// page number is used in the media query of the theme color. E.g. for
// page 1, the viewport includes:
//
// <meta name="theme-color" media="(min-width: 1px)" content="light"/>
// First we should prefetch all the route trees:
{ includes: '(min-width: 7px)' },
{ includes: '(min-width: 6px)' },
{ includes: '(min-width: 5px)' },
{ includes: '(min-width: 4px)' },
{ includes: '(min-width: 3px)' },
// Then we should prefetch the segments:
{ includes: 'Content of page 7' },
{ includes: 'Content of page 6' },
{ includes: 'Content of page 5' },
{ includes: 'Content of page 4' },
{ includes: 'Content of page 3' },
])
})
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)
},
})
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.
//
// We hover over links near the bottom of the document (pages 6 and 7).
// By default the scheduler prioritizes the links nearest the top, so
// these links' route trees have *not* already been requested — which is
// what lets us observe that hovering initiates a new request that
// exceeds the default bandwidth limit.
const link6 = await browser.elementByCss('a[href="/cancellation/6"]')
await link6.hover()
// Immediately hover over a different link.
const link7 = await browser.elementByCss('a[href="/cancellation/7"]')
await link7.hover()
}, [
// The most recently hovered link is allowed to finish loading its
// segment data.
{ includes: 'Content of page 7' },
// The previously hovered link was downgraded to the default priority,
// so it should still be blocked.
{ includes: 'Content of page 6', 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 6' },
// 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 ' +
'before it finishes',
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. Because the router limits the number of
// concurrent prefetches, not all the links will start prefetching —
// some of them will remain in the queue, waiting for additional
// network bandwidth. This test demonstrates that those prefetches
// will be canceled on viewport exit, too.
await act(async () => {
await checkbox.click()
}, 'block')
// Before the prefetch finishes, click the checkbox again to hide
// the link.
await checkbox.click()
},
// When the outer `act` scope finishes, the route tree prefetch will
// continue. Normally when the router is done prefetching the route
// tree, it will proceed to prefetching the segments. However, since
// the link is no longer visible, it should stop prefetching.
//
// Assert that no additional network requests are initiated in this
// outer scope. If this fails, it suggests that the prefetches were not
// canceled when the links left the viewport.
'no-requests'
)
}
)
it("reschedules a link's prefetch when it re-enters the viewport", 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. Because the router limits the number of
// concurrent prefetches, not all the links will start prefetching —
// some of them will remain in the queue, waiting for additional
// network bandwidth. This test demonstrates that those prefetches
// will be canceled on viewport exit, too.
await act(async () => {
await checkbox.click()
}, 'block')
// Before the prefetch finishes, click the checkbox again to hide
// the link.
await checkbox.click()
},
// When the outer `act` scope finishes, the route tree prefetch will
// continue. Normally when the router is done prefetching the route
// tree, it will proceed to prefetching the segments. However, since
// the link is no longer visible, it should stop prefetching.
//
// Assert that no additional network requests are initiated in this
// outer scope. If this fails, it suggests that the prefetches were not
// canceled when the links left the viewport.
'no-requests'
)
// Now we'll reveal the links again to verify that the prefetch tasks are
// rescheduled, after having been canceled.
await act(
async () => {
await checkbox.click()
},
// Don't need to assert on all the prefetch responses. I picked an
// arbitrary one.
{ includes: 'Content of page 5' }
)
})
})