From 3425f8fabc793398ffffb77f43d0b94126abd7cd Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 06:57:50 +0100 Subject: [PATCH 1/4] fix(link): schedule segment-cache prefetch phases --- packages/vinext/src/shims/link.tsx | 286 ++++++++++++++++++++++++++--- tests/link-navigation.test.ts | 61 ++++++ 2 files changed, 322 insertions(+), 25 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index a5b7cc0cd..d9f179237 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -114,7 +114,13 @@ type LinkProps = { children?: React.ReactNode; } & Omit, "href">; -type LinkPrefetchMode = "disabled" | "auto" | "full" | "full-after-shell"; +type LinkPrefetchMode = + | "disabled" + | "auto" + | "full" + | "full-after-shell" + | "route-tree" + | "segment"; declare global { // Window is an ambient interface from lib.dom; interface merging is required @@ -395,15 +401,16 @@ function resolveFullAppRoutePrefetch(): { * For Pages Router: warms the page chunk, prefetches data only for SSG pages, * and falls back to a document prefetch hint when no page loader matches. * - * Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking - * the main thread during initial page load. + * App Router and high-priority prefetches start immediately. Low-priority + * Pages Router fallback prefetches use `requestIdleCallback` (or `setTimeout` + * fallback) to avoid blocking the main thread during initial page load. */ function prefetchUrl( href: string, mode: LinkPrefetchMode, priority: "low" | "high" = "low", pagesRouteHref?: string, -): void { +): Promise | undefined { if (typeof window === "undefined") return; const prefetchHref = getLinkPrefetchHref({ @@ -436,15 +443,8 @@ function prefetchUrl( return; } - const schedule = - priority === "high" - ? (fn: () => void) => { - fn(); - } - : (window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100))); - - schedule(() => { - void (async () => { + const runPrefetch = () => { + const promise = (async () => { if (hasAppNavigationRuntime()) { if (isBotUserAgent(window.navigator?.userAgent ?? "")) return; @@ -473,7 +473,11 @@ function prefetchUrl( PREFETCH_CACHE_TTL, } = navigation; const { createRscRequestHeaders, createRscRequestUrl } = rscCacheBusting; - const { NEXT_ROUTER_PREFETCH_HEADER, VINEXT_MOUNTED_SLOTS_HEADER } = headersModule; + const { + NEXT_ROUTER_PREFETCH_HEADER, + NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + VINEXT_MOUNTED_SLOTS_HEADER, + } = headersModule; // Hybrid ownership: skip the App RSC prefetch when Pages owns the // URL. The App's `__VINEXT_LINK_PREFETCH_ROUTES__` may include an // App catch-all that also matches the same path, so a naive @@ -486,16 +490,19 @@ function prefetchUrl( return; } const autoPrefetch = - mode === "auto" - ? resolveAutoAppRoutePrefetch(prefetchHref) - : mode === "full-after-shell" - ? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true } - : resolveFullAppRoutePrefetch(); + mode === "route-tree" || mode === "segment" + ? { cacheForNavigation: false, prefetchShellFirst: false, shouldPrefetch: true } + : mode === "auto" + ? resolveAutoAppRoutePrefetch(prefetchHref) + : mode === "full-after-shell" + ? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true } + : resolveFullAppRoutePrefetch(); if (!autoPrefetch.shouldPrefetch) return; const interceptionContext = getPrefetchInterceptionContext(fullHref); const mountedSlotsHeader = getMountedSlotsHeader(); - const isOptimisticRouteShellPrefetch = !autoPrefetch.cacheForNavigation; + const isOptimisticRouteShellPrefetch = + mode !== "segment" && !autoPrefetch.cacheForNavigation; if (isOptimisticRouteShellPrefetch && interceptionContext !== null) return; const headers = createRscRequestHeaders({ interceptionContext, @@ -508,6 +515,9 @@ function prefetchUrl( } if (isOptimisticRouteShellPrefetch) { headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); + headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree"); + } else if (mode === "segment") { + headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_page"); } // Distinguish the same visible URL when it is prefetched from different // request contexts such as /feed vs /gallery or different mounted slots. @@ -549,6 +559,7 @@ function prefetchUrl( renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, }); shellHeaders.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); + shellHeaders.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree"); if (mountedSlotsHeader) { shellHeaders.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); } @@ -610,6 +621,8 @@ function prefetchUrl( optimisticRouteShell: isOptimisticRouteShellPrefetch, }, ); + const entry = getPrefetchCache().get(cacheKey); + await entry?.pending?.catch(() => {}); } else if (HAS_PAGES_ROUTER && window.__NEXT_DATA__) { // Pages Router prefetch. When a code-split loader is registered for // the target route (prod builds expose them on window via the @@ -650,6 +663,16 @@ function prefetchUrl( })().catch((error) => { console.error("[vinext] RSC prefetch setup error:", error); }); + return promise; + }; + + if (priority === "high" || hasAppNavigationRuntime()) { + return runPrefetch(); + } + + const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)); + schedule(() => { + void runPrefetch(); }); } @@ -697,21 +720,216 @@ type LinkPrefetchInstance = { mode: LinkPrefetchMode; pagesRouteHref?: string; routerMode: LinkPrefetchRouterMode; + scheduledTask: LinkPrefetchTask | null; viewportPrefetched: boolean; }; const observedLinkPrefetches = new WeakMap(); const visibleLinkPrefetches = new Set(); -function setVisibleLinkPrefetch(instance: LinkPrefetchInstance, isVisible: boolean): void { +type LinkPrefetchTask = { + instance: LinkPrefetchInstance; + batchId: number; + phase: "route-tree" | "segment"; + priority: "low" | "high"; + sortId: number; + canceled: boolean; + queued: boolean; +}; + +const scheduledLinkPrefetches: LinkPrefetchTask[] = []; +let scheduledLinkPrefetchBatchId = 0; +let scheduledLinkPrefetchSortId = 0; +let scheduledLinkPrefetchInProgress = 0; +let scheduledLinkPrefetchPendingMicrotask = false; +let mostRecentIntentPrefetchTask: LinkPrefetchTask | null = null; + +function usesSegmentCachePrefetchScheduler(instance: LinkPrefetchInstance): boolean { + return ( + instance.routerMode === "app" && + instance.mode === "auto" && + String(process.env.__NEXT_CACHE_COMPONENTS) === "true" + ); +} + +function scheduleMicrotaskForLinkPrefetch(fn: () => void): void { + if (typeof queueMicrotask === "function") { + queueMicrotask(fn); + } else { + Promise.resolve() + .then(fn) + .catch((error) => { + setTimeout(() => { + throw error; + }); + }); + } +} + +function pushLinkPrefetchTask(task: LinkPrefetchTask): void { + if (!task.queued) { + task.queued = true; + scheduledLinkPrefetches.push(task); + } +} + +function removeLinkPrefetchTask(task: LinkPrefetchTask): void { + if (!task.queued) return; + task.queued = false; + const index = scheduledLinkPrefetches.indexOf(task); + if (index !== -1) scheduledLinkPrefetches.splice(index, 1); +} + +function scheduleLinkPrefetchQueue(delayUntilNextTask = false): void { + if (scheduledLinkPrefetchPendingMicrotask) return; + scheduledLinkPrefetchPendingMicrotask = true; + if (delayUntilNextTask) { + setTimeout(processLinkPrefetchQueue, 0); + return; + } + scheduleMicrotaskForLinkPrefetch(processLinkPrefetchQueue); +} + +function compareLinkPrefetchTasks(a: LinkPrefetchTask, b: LinkPrefetchTask): number { + const priorityDelta = (a.priority === "high" ? 1 : 0) - (b.priority === "high" ? 1 : 0); + if (priorityDelta !== 0) return priorityDelta; + const phaseDelta = (a.phase === "route-tree" ? 1 : 0) - (b.phase === "route-tree" ? 1 : 0); + if (phaseDelta !== 0) return phaseDelta; + return a.sortId - b.sortId; +} + +function hasLinkPrefetchBandwidth(task: LinkPrefetchTask): boolean { + if (task.phase === "route-tree" && task.priority !== "high") { + for (const queuedTask of scheduledLinkPrefetches) { + if ( + queuedTask !== task && + queuedTask.priority !== "high" && + queuedTask.phase === "route-tree" && + queuedTask.batchId < task.batchId + ) { + return false; + } + } + } + return scheduledLinkPrefetchInProgress < (task.priority === "high" ? 12 : 4); +} + +function peekNextRunnableLinkPrefetchTask(): LinkPrefetchTask | null { + let bestTask: LinkPrefetchTask | null = null; + for (const task of scheduledLinkPrefetches) { + if (!hasLinkPrefetchBandwidth(task)) continue; + if (bestTask === null || compareLinkPrefetchTasks(task, bestTask) > 0) { + bestTask = task; + } + } + return bestTask; +} + +function trackIntentLinkPrefetchTask(task: LinkPrefetchTask): void { + if (task.priority !== "high" || task === mostRecentIntentPrefetchTask) return; + if (mostRecentIntentPrefetchTask !== null) { + mostRecentIntentPrefetchTask.priority = "low"; + } + mostRecentIntentPrefetchTask = task; +} + +function scheduleSegmentCacheLinkPrefetch( + instance: LinkPrefetchInstance, + priority: "low" | "high", + batchId = instance.scheduledTask?.batchId ?? scheduledLinkPrefetchBatchId++, +): void { + let task = instance.scheduledTask; + if (task === null) { + task = { + instance, + batchId, + phase: "route-tree", + priority, + sortId: scheduledLinkPrefetchSortId++, + canceled: false, + queued: false, + }; + instance.scheduledTask = task; + } else { + task.batchId = batchId; + task.canceled = false; + task.phase = "route-tree"; + task.priority = task === mostRecentIntentPrefetchTask && priority === "low" ? "high" : priority; + task.sortId = scheduledLinkPrefetchSortId++; + } + + trackIntentLinkPrefetchTask(task); + pushLinkPrefetchTask(task); + scheduleLinkPrefetchQueue(priority === "high"); +} + +function cancelScheduledSegmentCacheLinkPrefetch(instance: LinkPrefetchInstance): void { + const task = instance.scheduledTask; + if (task === null) return; + task.canceled = true; + removeLinkPrefetchTask(task); + if (mostRecentIntentPrefetchTask === task) { + mostRecentIntentPrefetchTask = null; + } +} + +function processLinkPrefetchQueue(): void { + scheduledLinkPrefetchPendingMicrotask = false; + + let task = peekNextRunnableLinkPrefetchTask(); + while (task !== null) { + removeLinkPrefetchTask(task); + if (task.canceled || !task.instance.isVisible) { + task = peekNextRunnableLinkPrefetchTask(); + continue; + } + + const currentTask = task; + scheduledLinkPrefetchInProgress++; + const phase = currentTask.phase; + const mode: LinkPrefetchMode = phase === "route-tree" ? "route-tree" : "segment"; + const promise = prefetchUrl( + currentTask.instance.href, + mode, + currentTask.priority, + currentTask.instance.pagesRouteHref, + ); + + Promise.resolve(promise) + .catch(() => {}) + .finally(() => { + scheduledLinkPrefetchInProgress--; + if (!currentTask.canceled && currentTask.instance.isVisible && phase === "route-tree") { + currentTask.phase = "segment"; + pushLinkPrefetchTask(currentTask); + } + scheduleLinkPrefetchQueue(); + }); + + task = peekNextRunnableLinkPrefetchTask(); + } +} + +function setVisibleLinkPrefetch( + instance: LinkPrefetchInstance, + isVisible: boolean, + batchId = scheduledLinkPrefetchBatchId++, +): void { instance.isVisible = isVisible; if (isVisible) { visibleLinkPrefetches.add(instance); if (instance.routerMode === "pages" && instance.viewportPrefetched) return; - prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + if (usesSegmentCachePrefetchScheduler(instance)) { + scheduleSegmentCacheLinkPrefetch(instance, "low", batchId); + } else { + void prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + } instance.viewportPrefetched = true; } else { visibleLinkPrefetches.delete(instance); + if (usesSegmentCachePrefetchScheduler(instance)) { + cancelScheduledSegmentCacheLinkPrefetch(instance); + } } } @@ -723,7 +941,11 @@ function registerVisibleLinkPing(): void { function pingVisibleLinkPrefetches(): void { for (const instance of visibleLinkPrefetches) { if (instance.isVisible && instance.routerMode === "app") { - prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + if (usesSegmentCachePrefetchScheduler(instance)) { + scheduleSegmentCacheLinkPrefetch(instance, "low"); + } else { + void prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + } } } } @@ -734,10 +956,15 @@ function getSharedObserver(): IntersectionObserver | null { sharedObserver = new IntersectionObserver( (entries) => { + const batchId = scheduledLinkPrefetchBatchId++; for (const entry of entries) { const instance = observedLinkPrefetches.get(entry.target); if (!instance) continue; - setVisibleLinkPrefetch(instance, entry.isIntersecting || entry.intersectionRatio > 0); + setVisibleLinkPrefetch( + instance, + entry.isIntersecting || entry.intersectionRatio > 0, + batchId, + ); } }, { @@ -1040,6 +1267,7 @@ const Link = forwardRef(function Link( currentOrigin: window.location.origin, }) ?? undefined), routerMode: getLinkPrefetchRouterMode(), + scheduledTask: null, viewportPrefetched: false, }; observedLinkPrefetches.set(node, instance); @@ -1047,6 +1275,7 @@ const Link = forwardRef(function Link( return () => { observer.unobserve(node); + cancelScheduledSegmentCacheLinkPrefetch(instance); observedLinkPrefetches.delete(node); visibleLinkPrefetches.delete(instance); }; @@ -1071,7 +1300,14 @@ const Link = forwardRef(function Link( } void promotePrefetchEntriesForNavigation(normalizedHref); } - prefetchUrl( + if (internalRef.current) { + const instance = observedLinkPrefetches.get(internalRef.current); + if (instance && usesSegmentCachePrefetchScheduler(instance) && instance.isVisible) { + scheduleSegmentCacheLinkPrefetch(instance, "high"); + return; + } + } + void prefetchUrl( normalizedHref, intentMode, "high", diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index d76084471..9936357c8 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -11,6 +11,7 @@ import { import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { NEXT_ROUTER_PREFETCH_HEADER, + NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, } from "../packages/vinext/src/server/headers.js"; import type { VinextLinkPrefetchRoute } from "../packages/vinext/src/client/vinext-next-data.js"; @@ -1310,6 +1311,66 @@ describe("Link prefetch scheduling", () => { } }); + it("splits cacheComponents auto prefetches into route-tree and segment phases", async () => { + // Ported from the request-order contract covered by Next.js: + // test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + const observer = stubIntersectionObserver(); + let releaseRouteTreeBody: (() => void) | undefined; + const routeTreeBody = new ReadableStream({ + start(controller) { + releaseRouteTreeBody = () => { + controller.close(); + }; + }, + }); + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + + result.fetch + .mockImplementationOnce(() => Promise.resolve(new Response(routeTreeBody))) + .mockImplementationOnce(() => Promise.resolve(new Response("segment"))); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const routeTreeInit = result.fetch.mock.calls[0]?.[1]; + expect(routeTreeInit?.headers).toBeInstanceOf(Headers); + if (!(routeTreeInit?.headers instanceof Headers)) { + throw new Error("Expected route-tree prefetch request headers"); + } + expect(routeTreeInit.headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1"); + expect(routeTreeInit.headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_tree"); + expect(routeTreeInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe( + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); + + await flushPrefetchTasks(); + expect(result.fetch).toHaveBeenCalledTimes(1); + + if (releaseRouteTreeBody === undefined) { + throw new Error("Expected route-tree body release"); + } + releaseRouteTreeBody(); + await waitForFetchCalls(result.fetch, 2); + + const segmentInit = result.fetch.mock.calls[1]?.[1]; + expect(segmentInit?.headers).toBeInstanceOf(Headers); + if (!(segmentInit?.headers instanceof Headers)) { + throw new Error("Expected segment prefetch request headers"); + } + expect(segmentInit.headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBeNull(); + expect(segmentInit.headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_page"); + expect(segmentInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBeNull(); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not prefetch visible or hovered links for a bot user agent", async () => { // Ported from Next.js: // test/e2e/app-dir/app-prefetch/prefetching.test.ts From 31216299443037537fbfbd54612a0164478554be Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 07:03:25 +0100 Subject: [PATCH 2/4] fix(link): align segment prefetch scheduling parity --- packages/vinext/src/shims/link.tsx | 4 ++- tests/link-navigation.test.ts | 51 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index d9f179237..3d3fd3b2c 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -517,6 +517,7 @@ function prefetchUrl( headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree"); } else if (mode === "segment") { + headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_page"); } // Distinguish the same visible URL when it is prefetched from different @@ -525,11 +526,12 @@ function prefetchUrl( const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext); const prefetched = getPrefetchedUrls(); if (prefetched.has(cacheKey)) { + const existing = getPrefetchCache().get(cacheKey); if (!autoPrefetch.cacheForNavigation) { + await existing?.pending?.catch(() => {}); return; } - const existing = getPrefetchCache().get(cacheKey); if (existing?.cacheForNavigation === false) { existing.cacheForNavigation = true; } diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 9936357c8..7b8af4e88 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -1363,7 +1363,7 @@ describe("Link prefetch scheduling", () => { if (!(segmentInit?.headers instanceof Headers)) { throw new Error("Expected segment prefetch request headers"); } - expect(segmentInit.headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBeNull(); + expect(segmentInit.headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1"); expect(segmentInit.headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_page"); expect(segmentInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBeNull(); } finally { @@ -1371,6 +1371,55 @@ describe("Link prefetch scheduling", () => { } }); + it("keeps rescheduled cacheComponents prefetches blocked on the route-tree response", async () => { + // Ported from the hover-reschedule contract covered by Next.js: + // test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + const observer = stubIntersectionObserver(); + let releaseRouteTreeBody: (() => void) | undefined; + const routeTreeBody = new ReadableStream({ + start(controller) { + releaseRouteTreeBody = () => { + controller.close(); + }; + }, + }); + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + + result.fetch + .mockImplementationOnce(() => Promise.resolve(new Response(routeTreeBody))) + .mockImplementationOnce(() => Promise.resolve(new Response("segment"))); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledTimes(1); + + if (releaseRouteTreeBody === undefined) { + throw new Error("Expected route-tree body release"); + } + releaseRouteTreeBody(); + await waitForFetchCalls(result.fetch, 2); + + const segmentInit = result.fetch.mock.calls[1]?.[1]; + expect(segmentInit).toEqual( + expect.objectContaining({ + priority: "high", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not prefetch visible or hovered links for a bot user agent", async () => { // Ported from Next.js: // test/e2e/app-dir/app-prefetch/prefetching.test.ts From 68ea4fe34e4f93610b88927fd94428b5148d74a1 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 07:11:40 +0100 Subject: [PATCH 3/4] fix(app-router): skip scheduled prefetch intent for bots --- packages/vinext/src/shims/link.tsx | 44 ++++++++++-- tests/link-navigation.test.ts | 109 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 3d3fd3b2c..5c1d0b9ff 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -401,9 +401,9 @@ function resolveFullAppRoutePrefetch(): { * For Pages Router: warms the page chunk, prefetches data only for SSG pages, * and falls back to a document prefetch hint when no page loader matches. * - * App Router and high-priority prefetches start immediately. Low-priority - * Pages Router fallback prefetches use `requestIdleCallback` (or `setTimeout` - * fallback) to avoid blocking the main thread during initial page load. + * High-priority and Segment Cache scheduler phases start immediately. Other + * low-priority prefetches use `requestIdleCallback` (or `setTimeout` fallback) + * to avoid blocking the main thread during initial page load. */ function prefetchUrl( href: string, @@ -518,6 +518,9 @@ function prefetchUrl( headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree"); } else if (mode === "segment") { headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); + // Vinext serves one unified page payload instead of Next.js's real + // per-segment payloads, so this marker distinguishes the second + // scheduler phase without claiming a concrete Next.js segment key. headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_page"); } // Distinguish the same visible URL when it is prefetched from different @@ -668,7 +671,7 @@ function prefetchUrl( return promise; }; - if (priority === "high" || hasAppNavigationRuntime()) { + if (priority === "high" || mode === "route-tree" || mode === "segment") { return runPrefetch(); } @@ -736,7 +739,9 @@ type LinkPrefetchTask = { priority: "low" | "high"; sortId: number; canceled: boolean; + completed: boolean; queued: boolean; + running: boolean; }; const scheduledLinkPrefetches: LinkPrefetchTask[] = []; @@ -769,7 +774,7 @@ function scheduleMicrotaskForLinkPrefetch(fn: () => void): void { } function pushLinkPrefetchTask(task: LinkPrefetchTask): void { - if (!task.queued) { + if (!task.queued && !task.running && !task.completed) { task.queued = true; scheduledLinkPrefetches.push(task); } @@ -849,12 +854,26 @@ function scheduleSegmentCacheLinkPrefetch( priority, sortId: scheduledLinkPrefetchSortId++, canceled: false, + completed: false, queued: false, + running: false, }; instance.scheduledTask = task; } else { + if (task.running) { + task.canceled = false; + task.priority = + task === mostRecentIntentPrefetchTask && priority === "low" ? "high" : priority; + trackIntentLinkPrefetchTask(task); + return; + } + if (task.completed) { + return; + } + task.batchId = batchId; task.canceled = false; + task.completed = false; task.phase = "route-tree"; task.priority = task === mostRecentIntentPrefetchTask && priority === "low" ? "high" : priority; task.sortId = scheduledLinkPrefetchSortId++; @@ -881,13 +900,14 @@ function processLinkPrefetchQueue(): void { let task = peekNextRunnableLinkPrefetchTask(); while (task !== null) { removeLinkPrefetchTask(task); - if (task.canceled || !task.instance.isVisible) { + if (task.canceled || task.running || !task.instance.isVisible) { task = peekNextRunnableLinkPrefetchTask(); continue; } const currentTask = task; scheduledLinkPrefetchInProgress++; + currentTask.running = true; const phase = currentTask.phase; const mode: LinkPrefetchMode = phase === "route-tree" ? "route-tree" : "segment"; const promise = prefetchUrl( @@ -900,10 +920,16 @@ function processLinkPrefetchQueue(): void { Promise.resolve(promise) .catch(() => {}) .finally(() => { + currentTask.running = false; scheduledLinkPrefetchInProgress--; if (!currentTask.canceled && currentTask.instance.isVisible && phase === "route-tree") { currentTask.phase = "segment"; pushLinkPrefetchTask(currentTask); + } else if (!currentTask.canceled && phase === "segment") { + currentTask.completed = true; + if (mostRecentIntentPrefetchTask === currentTask) { + mostRecentIntentPrefetchTask = null; + } } scheduleLinkPrefetchQueue(); }); @@ -1284,16 +1310,20 @@ const Link = forwardRef(function Link( }, [shouldViewportPrefetch, prefetchMode, normalizedHref, normalizedRouteHref]); const prefetchOnIntent = useCallback(() => { + const routerMode = getLinkPrefetchRouterMode(); if ( !canLinkIntentPrefetch({ nodeEnv: process.env.NODE_ENV, prefetch: prefetchProp, isDangerous, - routerMode: getLinkPrefetchRouterMode(), + routerMode, }) ) { return; } + if (routerMode === "app" && isBotUserAgent(window.navigator?.userAgent ?? "")) { + return; + } const intentMode = unstable_dynamicOnHover ? "full-after-shell" : prefetchMode; if (unstable_dynamicOnHover && internalRef.current) { const instance = observedLinkPrefetches.get(internalRef.current); diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 7b8af4e88..fb78a6764 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -1420,6 +1420,115 @@ describe("Link prefetch scheduling", () => { } }); + it("continues cacheComponents segment prefetches when visibility returns during route-tree fetch", async () => { + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + const observer = stubIntersectionObserver(); + let releaseRouteTreeBody: (() => void) | undefined; + const routeTreeBody = new ReadableStream({ + start(controller) { + releaseRouteTreeBody = () => { + controller.close(); + }; + }, + }); + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + + result.fetch + .mockImplementationOnce(() => Promise.resolve(new Response(routeTreeBody))) + .mockImplementationOnce(() => Promise.resolve(new Response("segment"))); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + observer.dispatchIntersectingEntry(result.anchor, false); + await flushPrefetchTasks(); + observer.dispatchIntersectingEntry(result.anchor, true); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledTimes(1); + + if (releaseRouteTreeBody === undefined) { + throw new Error("Expected route-tree body release"); + } + releaseRouteTreeBody(); + await waitForFetchCalls(result.fetch, 2); + + const segmentInit = result.fetch.mock.calls[1]?.[1]; + expect(segmentInit?.headers).toBeInstanceOf(Headers); + if (!(segmentInit?.headers instanceof Headers)) { + throw new Error("Expected segment prefetch request headers"); + } + expect(segmentInit.headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_page"); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not restart completed cacheComponents prefetches on hover", async () => { + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 2); + await flushPrefetchTasks(); + + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + pingVisibleLinksFromRuntime(); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledTimes(2); + } finally { + result.restoreNodeEnv(); + } + }); + + it("keeps non-cacheComponents App viewport prefetches deferred until idle", async () => { + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "false"); + const observer = stubIntersectionObserver(); + const idleCallbacks: Array<() => void> = []; + const requestIdleCallback = vi.fn((callback: () => void) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }); + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + windowOverrides: { requestIdleCallback }, + }); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await flushPrefetchTasks(); + + expect(requestIdleCallback).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + + idleCallbacks[0]?.(); + await waitForFetchCalls(result.fetch, 1); + + expectCanonicalRscFetchCall( + result.fetch.mock.calls[0], + "/viewport-prefetch-target", + expect.objectContaining({ + credentials: "include", + priority: "low", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not prefetch visible or hovered links for a bot user agent", async () => { // Ported from Next.js: // test/e2e/app-dir/app-prefetch/prefetching.test.ts From 96fb702ed63e9534d4c3f91bdd12d8ae71948f1b Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 11:16:32 +0100 Subject: [PATCH 4/4] fix(app-router): reuse runtime segment prefetch payloads --- packages/vinext/src/shims/link.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index fa41fa09b..418bf6304 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -492,7 +492,11 @@ function prefetchUrl( } const autoPrefetch = mode === "route-tree" || mode === "segment" - ? { cacheForNavigation: false, prefetchShellFirst: false, shouldPrefetch: true } + ? { + cacheForNavigation: mode === "segment", + prefetchShellFirst: false, + shouldPrefetch: true, + } : mode === "auto" ? resolveAutoAppRoutePrefetch(prefetchHref) : mode === "full-after-shell" @@ -765,7 +769,7 @@ function drainVisibleAppPrefetchQueue(): void { if (!instance) return; instance.queuedViewportPrefetch = false; if (!instance.isVisible || instance.routerMode !== "app") continue; - prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + void prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); } } @@ -998,7 +1002,7 @@ function setVisibleLinkPrefetch( } else if (instance.routerMode === "app") { scheduleVisibleAppPrefetch(instance); } else { - prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); + void prefetchUrl(instance.href, instance.mode, "low", instance.pagesRouteHref); } instance.viewportPrefetched = true; } else {