diff --git a/packages/next/errors.json b/packages/next/errors.json index c86fb3498fa53..f3a918ac233fe 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -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" } diff --git a/packages/next/src/client/app-dir/link.tsx b/packages/next/src/client/app-dir/link.tsx index 753ce7f7c1bd7..0a4e1f262b3a4 100644 --- a/packages/next/src/client/app-dir/link.tsx +++ b/packages/next/src/client/app-dir/link.tsx @@ -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 = { @@ -112,11 +118,181 @@ export type LinkProps = InternalLinkProps type LinkPropsRequired = RequiredKeys type LinkPropsOptional = OptionalKeys> -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 + | Map = + typeof WeakMap === 'function' ? new WeakMap() : new Map() + +// A single IntersectionObserver instance shared by all 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 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) { + 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 } @@ -124,7 +300,9 @@ function prefetch( 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) @@ -394,9 +572,6 @@ const Link = React.forwardRef( } }, [hrefProp, asProp]) - const previousHref = React.useRef(href) - const previousAs = React.useRef(as) - // This will return the first child, if multiple are provided it will throw an error let child: any if (legacyBehavior) { @@ -443,47 +618,23 @@ const Link = React.forwardRef( ? 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 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 @@ -492,7 +643,7 @@ const Link = React.forwardRef( href?: string ref?: any } = { - ref: setRef, + ref: mergedRef, onClick(e) { if (process.env.NODE_ENV !== 'production') { if (!e) { @@ -545,9 +696,7 @@ const Link = React.forwardRef( return } - prefetch(router, href, { - kind: appPrefetchKind, - }) + onNavigationIntent(e.currentTarget as HTMLAnchorElement | SVGAElement) }, onTouchStart: process.env.__NEXT_LINK_NO_TOUCH_START ? undefined @@ -572,9 +721,9 @@ const Link = React.forwardRef( return } - prefetch(router, href, { - kind: appPrefetchKind, - }) + onNavigationIntent( + e.currentTarget as HTMLAnchorElement | SVGAElement + ) }, } diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index 0ba95ac86f973..e7bf26fbf91ee 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -90,22 +90,14 @@ export type PrefetchTask = { */ hasBackgroundWork: boolean - /** - * True if the prefetch is blocked by network data. We remove tasks from the - * queue once they are blocked, and add them back when they receive data. - * - * isBlocked also indicates whether the task is currently in the queue; tasks - * are removed from the queue when they are blocked. Use this to avoid - * queueing the same task multiple times. - */ - isBlocked: boolean - /** * The index of the task in the heap's backing array. Used to efficiently * change the priority of a task by re-sifting it, which requires knowing * where it is in the array. This is only used internally by the heap * algorithm. The naive alternative is indexOf every time a task is queued, * which has O(n) complexity. + * + * We also use this field to check whether a task is currently in the queue. */ _heapIndex: number } @@ -173,7 +165,7 @@ export function schedulePrefetchTask( key: RouteCacheKey, treeAtTimeOfPrefetch: FlightRouterState, includeDynamicData: boolean -): void { +): PrefetchTask { // Spawn a new prefetch task const task: PrefetchTask = { key, @@ -182,7 +174,6 @@ export function schedulePrefetchTask( hasBackgroundWork: false, includeDynamicData, sortId: sortIdCounter++, - isBlocked: false, _heapIndex: -1, } heapPush(taskHeap, task) @@ -195,6 +186,27 @@ export function schedulePrefetchTask( // If they have different priorities, it also ensures they are processed in // the optimal order. ensureWorkIsScheduled() + + return task +} + +export function bumpPrefetchTask(task: PrefetchTask): void { + // Bump the prefetch task to the top of the queue, as if it were a fresh + // task. This is essentially the same as canceling the task and scheduling + // a new one, except it reuses the original object. + // + // The primary use case is to increase the relative priority of a Link- + // initated prefetch on hover. + + // Assign a new sort ID. Higher sort IDs are higher priority. + task.sortId = sortIdCounter++ + if (task._heapIndex !== -1) { + // The task is already in the queue. + heapResift(taskHeap, task) + } else { + heapPush(taskHeap, task) + } + ensureWorkIsScheduled() } function ensureWorkIsScheduled() { @@ -263,12 +275,14 @@ function onPrefetchConnectionClosed(): void { */ export function pingPrefetchTask(task: PrefetchTask) { // "Ping" a prefetch that's already in progress to notify it of new data. - if (!task.isBlocked) { - // Prefetch is already queued. + if ( + // Check if prefetch is already queued. + // TODO: Check if task was canceled, too + task._heapIndex !== -1 + ) { return } - // Unblock the task and requeue it. - task.isBlocked = false + // Add the task back to the queue. heapPush(taskHeap, task) ensureWorkIsScheduled() } @@ -300,10 +314,8 @@ function processQueueInMicrotask() { case PrefetchTaskExitStatus.Blocked: // The task is blocked. It needs more data before it can proceed. // Keep the task out of the queue until the server responds. - task.isBlocked = true - - // Continue to the next task heapPop(taskHeap) + // Continue to the next task task = heapPeek(taskHeap) continue case PrefetchTaskExitStatus.Done: diff --git a/packages/next/src/shared/lib/router/action-queue.ts b/packages/next/src/shared/lib/router/action-queue.ts index 40a5eb9c22250..7c62b78b2d62c 100644 --- a/packages/next/src/shared/lib/router/action-queue.ts +++ b/packages/next/src/shared/lib/router/action-queue.ts @@ -174,6 +174,8 @@ function dispatchAction( } } +let globalActionQueue: AppRouterActionQueue | null = null + export function createMutableActionQueue( initialState: AppRouterState ): AppRouterActionQueue { @@ -189,5 +191,22 @@ export function createMutableActionQueue( last: null, } + if (typeof window !== 'undefined') { + // The action queue is lazily created on hydration, but after that point + // it doesn't change. So we can store it in a global rather than pass + // it around everywhere via props/context. + if (globalActionQueue !== null) { + throw new Error( + 'Internal Next.js Error: createMutableActionQueue was called more ' + + 'than once' + ) + } + globalActionQueue = actionQueue + } + return actionQueue } + +export function getCurrentAppRouterState(): AppRouterState | null { + return globalActionQueue !== null ? globalActionQueue.state : null +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/[pageNumber]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/[pageNumber]/page.tsx new file mode 100644 index 0000000000000..32653a296aec4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/[pageNumber]/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from 'react' + +type Params = { + pageNumber: string +} + +async function Content({ params }: { params: Promise }) { + const { pageNumber } = await params + return 'Content of page ' + pageNumber +} + +export default async function LinkCancellationTargetPage({ + params, +}: { + params: Promise +}) { + return ( + + + + ) +} + +export async function generateStaticParams(): Promise> { + const result: Array = [] + for (let n = 1; n <= 1; n++) { + result.push({ pageNumber: n.toString() }) + } + return result +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/page.tsx new file mode 100644 index 0000000000000..44cc0a46173aa --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/cancellation/page.tsx @@ -0,0 +1,64 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +type PaginationConfig = { + start: number + end: number +} + +export default function LinkCancellationPage() { + const [areLinksVisible, setLinksVisibility] = useState(false) + const [showMoreLinks, setShowMoreLinks] = useState(false) + + return ( + <> +

+ This page is used to test that a prefetch scheduled when a Link enters + the viewport is cancelled when the Link exits. The visibility toggle + does not affect whether the links are mounted, only whether they are + visible (using the `hidden` attribute). +

+ + + + ) +} + +function Links({ start, end }: PaginationConfig) { + const links: Array = [] + for (let pageNumber = start; pageNumber <= end; pageNumber++) { + links.push( +
  • + + Link to page {pageNumber} + +
  • + ) + } + return links +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/layout.tsx new file mode 100644 index 0000000000000..dbce4ea8e3aeb --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js new file mode 100644 index 0000000000000..a74129c5a24f2 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + dynamicIO: true, + clientSegmentCache: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts b/test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts new file mode 100644 index 0000000000000..932e7ba43a911 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts @@ -0,0 +1,61 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from '../router-act' + +describe('segment cache prefetch scheduling', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + if (isNextDev || skipped) { + test('prefetching is disabled', () => {}) + return + } + + it('increases the priority of a viewport-initiated prefetch on hover', async () => { + // TODO: This works because we bump the prefetch task to the front of the + // queue on mouseenter. But there's a flaw: if another link enters the + // viewport while the first link is still being hovered, the second link + // will go ahead of it in the queue. In other words, we currently don't + // treat mouseenter as a higher priority signal than "viewport enter". To + // fix this, we need distinct priority levels for hover and viewport; the + // last-in-first-out strategy is not sufficient for the desired behavior. + let act: ReturnType + 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() + + // Hover over a different link to increase its relative priority. + const link5 = await browser.elementByCss('a[href="/cancellation/5"]') + await link5.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 5' }, + // The second-to-last link we hovered over should come next. + { includes: 'Content of page 2' }, + // 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' }, + ] + ) + }) +})