-
Notifications
You must be signed in to change notification settings - Fork 349
fix(app-router): align dynamic-on-hover prefetches #2464
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
base: main
Are you sure you want to change the base?
Changes from 2 commits
380af32
76dfd04
d985db6
f7e5aa6
e1a5865
7ec9a23
486b3a5
eafca0a
c04ae67
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -114,7 +114,7 @@ type LinkProps = { | |
| children?: React.ReactNode; | ||
| } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">; | ||
|
|
||
| type LinkPrefetchMode = "disabled" | "auto" | "full" | "full-after-shell"; | ||
| type LinkPrefetchMode = "disabled" | "auto" | "auto-shell" | "full" | "full-after-shell"; | ||
|
|
||
| declare global { | ||
| // Window is an ambient interface from lib.dom; interface merging is required | ||
|
|
@@ -395,8 +395,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. | ||
| * | ||
| * 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, | ||
|
|
@@ -436,14 +437,7 @@ function prefetchUrl( | |
| return; | ||
| } | ||
|
|
||
| const schedule = | ||
| priority === "high" | ||
| ? (fn: () => void) => { | ||
| fn(); | ||
| } | ||
| : (window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100))); | ||
|
|
||
| schedule(() => { | ||
| const runPrefetch = () => { | ||
| void (async () => { | ||
| if (hasAppNavigationRuntime()) { | ||
| if (isBotUserAgent(window.navigator?.userAgent ?? "")) return; | ||
|
|
@@ -452,7 +446,10 @@ function prefetchUrl( | |
| navigation, | ||
| { AppElementsWire }, | ||
| rscCacheBusting, | ||
| { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL }, | ||
| { | ||
| APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL, | ||
| APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, | ||
| }, | ||
| headersModule, | ||
| { resolveHybridClientRouteOwner }, | ||
| ] = await Promise.all([ | ||
|
|
@@ -473,7 +470,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 | ||
|
|
@@ -488,9 +489,11 @@ function prefetchUrl( | |
| const autoPrefetch = | ||
| mode === "auto" | ||
| ? resolveAutoAppRoutePrefetch(prefetchHref) | ||
| : mode === "full-after-shell" | ||
| ? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true } | ||
| : resolveFullAppRoutePrefetch(); | ||
| : mode === "auto-shell" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This adds |
||
| ? { cacheForNavigation: false, prefetchShellFirst: true, shouldPrefetch: true } | ||
| : mode === "full-after-shell" | ||
| ? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true } | ||
| : resolveFullAppRoutePrefetch(); | ||
| if (!autoPrefetch.shouldPrefetch) return; | ||
|
|
||
| const interceptionContext = getPrefetchInterceptionContext(fullHref); | ||
|
|
@@ -501,13 +504,16 @@ function prefetchUrl( | |
| interceptionContext, | ||
| renderMode: isOptimisticRouteShellPrefetch | ||
| ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL | ||
| : undefined, | ||
| : mode === "full-after-shell" | ||
| ? APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL | ||
| : undefined, | ||
| }); | ||
| if (mountedSlotsHeader) { | ||
| headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); | ||
| } | ||
| if (isOptimisticRouteShellPrefetch) { | ||
| headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); | ||
| headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree"); | ||
| } | ||
| // Distinguish the same visible URL when it is prefetched from different | ||
| // request contexts such as /feed vs /gallery or different mounted slots. | ||
|
|
@@ -549,6 +555,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); | ||
| } | ||
|
|
@@ -583,6 +590,14 @@ function prefetchUrl( | |
| shellEntry = shellCache.get(shellCacheKey); | ||
| } | ||
| await shellEntry?.pending?.catch(() => {}); | ||
| const settledShellEntry = shellCache.get(shellCacheKey); | ||
| if ( | ||
| settledShellEntry?.outcome !== "cache-seeded" || | ||
| settledShellEntry.expiresAt === undefined || | ||
| settledShellEntry.expiresAt <= Date.now() | ||
| ) { | ||
| throw new Error("Unable to upgrade prefetch without a fresh shell payload"); | ||
| } | ||
| return fetch(rscUrl, { | ||
| headers, | ||
| credentials: "include", | ||
|
|
@@ -650,7 +665,15 @@ function prefetchUrl( | |
| })().catch((error) => { | ||
| console.error("[vinext] RSC prefetch setup error:", error); | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
| if (priority === "high" || hasAppNavigationRuntime()) { | ||
| runPrefetch(); | ||
| return; | ||
| } | ||
|
|
||
| const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)); | ||
| schedule(runPrefetch); | ||
| } | ||
|
|
||
| async function promotePrefetchEntriesForNavigation(href: string): Promise<void> { | ||
|
|
@@ -1027,10 +1050,12 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |
| if (!observer) return; | ||
|
|
||
| registerVisibleLinkPing(); | ||
| const viewportPrefetchMode = | ||
| unstable_dynamicOnHover && prefetchMode === "auto" ? "auto-shell" : prefetchMode; | ||
| const instance: LinkPrefetchInstance = { | ||
| href: hrefToPrefetch, | ||
| isVisible: false, | ||
| mode: prefetchMode, | ||
| mode: viewportPrefetchMode, | ||
| pagesRouteHref: | ||
| normalizedRouteHref === normalizedHref | ||
| ? undefined | ||
|
|
@@ -1050,7 +1075,13 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |
| observedLinkPrefetches.delete(node); | ||
| visibleLinkPrefetches.delete(instance); | ||
| }; | ||
| }, [shouldViewportPrefetch, prefetchMode, normalizedHref, normalizedRouteHref]); | ||
| }, [ | ||
| shouldViewportPrefetch, | ||
| prefetchMode, | ||
| normalizedHref, | ||
| normalizedRouteHref, | ||
| unstable_dynamicOnHover, | ||
| ]); | ||
|
|
||
| const prefetchOnIntent = useCallback(() => { | ||
| if ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reuses
ClientReuseManifestSkipDispositionand returnsSKIP_STATIC_LAYOUT_VERIFIEDdirectly from currentlayoutFlags, but this path is not the same invariant as the existing manifest/cache-proof verifier inskip-cache-proof.ts; it depends on the client having a fresh shell payload, not on server-side cache proof. Reusing the verified disposition muddies the boundary and makes later readers think the normal proof pipeline ran. Can we add an explicit shell-delta skip model/code, or move this behind a helper in the skip/cache-proof layer with a name that states the prerequisite?