Skip to content

fix(link): align visible and intent prefetching#1258

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-prefetch-registry
May 16, 2026
Merged

fix(link): align visible and intent prefetching#1258
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-prefetch-registry

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Overview

Item Detail
Goal Bring vinext Link prefetch scheduling closer to Next.js App and Pages Router behavior.
Core change Track mounted and visible App Router Link prefetch instances, and split intent eligibility by router mode.
Boundary Link owns DOM visibility and intent strategy. Navigation/cache owners only ping visible App Router links through an internal browser hook.
Primary files packages/vinext/src/shims/link.tsx, packages/vinext/src/shims/link-prefetch.ts, packages/vinext/src/shims/navigation.ts, tests/link-navigation.test.ts
Expected impact App Router visible links can re-prefetch after cache/router changes, dynamic links can upgrade on hover, and Pages Router prefetch={false} still prefetches on hover/touch intent.

Why

Correct Link prefetching depends on two separate contracts. App Router prefetching needs mounted Link state to survive beyond the first viewport hit so visible links can be rescheduled when cache or router inputs change. Pages Router has a different prefetch={false} contract: it disables viewport prefetch, but hover and touch intent still prefetch.

Area Principle / invariant What this PR changes
Visibility A visible App Router Link remains a scheduling participant until it leaves the viewport or unmounts. Keeps observed instances in a WeakMap and visible App Router instances in a Set.
Cache invalidation Clearing App Router prefetch data should give currently visible links a chance to refill it. invalidatePrefetchCache() pings visible App Router links after clearing the cache.
Router context Mounted slot/router-state changes may affect App Router prefetch compatibility. Browser router commits ping visible links after mounted slot state is refreshed.
App Router intent prefetch={false} disables both viewport and hover/touch prefetch. Intent eligibility preserves the stricter App Router gate.
Pages Router intent prefetch={false} disables viewport prefetch only. Pages Router hover/touch intent still creates a high-priority document prefetch.
Dynamic hover Hover can raise priority and, for unstable_dynamicOnHover, upgrade strategy. Hover updates the visible instance to full prefetch before issuing the high-priority request.

What changed

Scenario Before After
App Router static visible Link Prefetched once, then unobserved. Prefetches on first visibility and remains tracked for future App Router pings.
App Router cache invalidation Visible Link prefetch cache stayed empty until another user action. Visible links re-prefetch after invalidation.
App Router dynamic auto Link Viewport auto prefetch correctly skipped full RSC fetch, but hover could not opt into full prefetch. unstable_dynamicOnHover upgrades hover to full prefetch and persists that strategy for later pings.
Pages Router prefetch={false} viewport Disabled. Still disabled.
Pages Router prefetch={false} hover/touch Incorrectly disabled by the shared gate. Intent prefetch now runs, matching Next.js Pages Router.
Pages Router viewport re-entry Persistent observation could duplicate document prefetch links. Pages viewport prefetch is one-shot per mounted link and skipped by visible-link pings.
Link typings Local shim accepted narrower prefetch type. Typing now accepts `boolean
Maintainer review path
  1. packages/vinext/src/shims/link.tsx: registry shape, router-mode detection, visibility transitions, intent strategy upshift, Pages one-shot viewport behavior, cleanup.
  2. packages/vinext/src/shims/link-prefetch.ts: trigger/router-mode eligibility split.
  3. packages/vinext/src/shims/navigation.ts: cache invalidation and committed URL/router-state ping points.
  4. packages/vinext/src/server/app-browser-entry.ts: mounted slot header update before visible-link ping.
  5. tests/link-navigation.test.ts: regression coverage for persistent observation, invalidation refill, dynamic hover upgrade, App Router prefetch={false} intent suppression, Pages Router prefetch={false} hover/touch intent, and Pages viewport re-entry dedupe.
Validation
  • vp test run tests/link-navigation.test.ts tests/link.test.ts: 84 tests passed.
  • vp check: formatting, lint, and type checks passed.
  • Commit hooks also ran vp check --fix and knip --no-progress successfully.
Risk / compatibility
  • Public API: aligns local next/link shim typing with supported runtime values. unstable_dynamicOnHover remains explicitly unstable.
  • Runtime: the visible-link ping hook is optional and browser-only. Navigation/cache code does not import Link, avoiding a circular dependency.
  • RSC cache: App Router pings still go through the existing RSC cache key and dedupe path, so duplicate visible pings should not duplicate in-flight or cached RSC fetches.
  • Pages Router: intent prefetch now matches Next.js Pages Router semantics for prefetch={false}. Pages viewport prefetch stays one-shot to avoid duplicate <link rel="prefetch"> nodes under the persistent observer.
Non-goals
  • This does not implement Next.js segment-cache or PPR partial prefetching.
  • This does not add a full Next.js scheduler with task cancellation and priority queues.
  • This does not change the current App Router dynamic-route auto-prefetch policy, which still avoids full automatic RSC prefetch unless explicitly requested or upgraded by hover intent.

References

Reference Why it matters
Next.js App Router Link instance and visible registry Next tracks mounted prefetchable elements separately from the visible set.
Next.js App Router visibility and intent rescheduling Visibility and hover both call into the same rescheduling path, with hover able to switch to full prefetch.
Next.js App Router pingVisibleLinks Visible links are rescheduled after cache/router inputs change.
Next.js unstable_dynamicOnHover prop Documents the hidden prop as a hover-time full-prefetch upgrade.
Next.js App Router prefetch={false} intent gate App Router disables hover/touch intent when prefetching is disabled.
Next.js Pages Router viewport gate Pages Router applies prefetchEnabled to viewport prefetch.
Next.js Pages Router hover/touch intent path Pages Router hover/touch still prefetch without checking prefetchEnabled.
Next.js dynamic-on-hover test Confirms hover should prefetch dynamic data and avoid additional requests on navigation.

Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover.

The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent.

Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1258

commit: f20410a

Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract.

Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry.

Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe.
@NathanDrake2406 NathanDrake2406 changed the title fix(link): reschedule visible prefetches fix(link): align visible and intent prefetching May 16, 2026
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(link): align visible and intent prefetching

This is a well-structured PR that brings vinext's Link prefetch lifecycle significantly closer to Next.js behavior. The architecture is clean: a WeakMap tracks per-element instance state, a Set tracks visible App Router links for ping-based rescheduling, and the global __VINEXT_PING_VISIBLE_LINKS__ hook avoids circular dependencies between link.tsx and navigation.ts. I verified all 84 tests pass.

What works well

  • Separation of concerns: The observedLinkPrefetches WeakMap and visibleLinkPrefetches Set cleanly separate DOM-element lifetime from visibility scheduling, matching the Next.js links.ts architecture.
  • Router-mode split for intent eligibility: The canLinkIntentPrefetch function correctly gates App Router prefetch={false} (disables both viewport and intent) while allowing Pages Router prefetch={false} to still prefetch on hover/touch — matching the divergent Next.js contracts.
  • Pages one-shot viewport dedup: The viewportPrefetched flag prevents duplicate <link rel="prefetch"> nodes when Pages links re-enter the viewport or receive pings, which is the right behavior since document prefetch links are persistent DOM nodes.
  • Cache invalidation re-prefetch flow: invalidatePrefetchCache() clears both the cache and the prefetched URL set before pinging, so the prefetched.has(cacheKey) guard inside prefetchUrl doesn't block re-prefetch — this is correct.
  • unstable_dynamicOnHover upgrade: The hover-time mode upgrade persists to the instance so subsequent pings re-prefetch with "full" — neat design.
  • Test coverage: The new tests cover the key behavioral contracts (persistent observation, cache invalidation refill, dynamic hover upgrade, router-mode intent split, Pages viewport dedup).

Minor comments below — none blocking.


function registerVisibleLinkPing(): void {
if (typeof window === "undefined") return;
window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: registerVisibleLinkPing is called on every effect setup (every mount of every prefetchable Link). The function is cheap (one property write), but it unconditionally overwrites window.__VINEXT_PING_VISIBLE_LINKS__ on every call. Since it always assigns the same function reference, this is harmless, but you could guard it with a module-level boolean to make the intent clearer and avoid the repeated write:

Suggested change
window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches;
function registerVisibleLinkPing(): void {
if (typeof window === "undefined" || window.__VINEXT_PING_VISIBLE_LINKS__) return;
window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches;
}

This also documents that the hook is meant to be installed once.

}

function pingVisibleLinkPrefetches(): void {
for (const instance of visibleLinkPrefetches) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instance.isVisible check inside pingVisibleLinkPrefetches is always true by construction — instances are only in visibleLinkPrefetches when isVisible is true (added on setVisibleLinkPrefetch(_, true), removed on setVisibleLinkPrefetch(_, false)). The check is defensive and doesn't hurt, but worth a comment explaining it's a safety belt rather than a reachable branch, so future readers don't wonder when it could be false.

return;
}
const intentMode = unstable_dynamicOnHover ? "full" : prefetchMode;
if (unstable_dynamicOnHover && internalRef.current) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observation: The unstable_dynamicOnHover instance upgrade mutates instance.mode to "full", but there's no path that reverts it. If the user navigates away and back (re-mount), a new instance is created so that's fine. But if the localizedHref or prefetchMode changes (effect deps), the cleanup removes the old instance and creates a new one with the original mode — also correct.

Just want to confirm: is there a scenario where the user hovers, the instance gets upgraded to "full", then the component re-renders with unstable_dynamicOnHover={false} without the effect re-running? Since unstable_dynamicOnHover is not in the viewport-prefetch effect's dependency array (only in prefetchOnIntent's useCallback deps), the instance created by the viewport effect would stay upgraded. This seems unlikely in practice (the prop is static), but worth noting.

}
const instance = observedLinkPrefetches.get(entry.target);
if (!instance) continue;
setVisibleLinkPrefetch(instance, entry.isIntersecting || entry.intersectionRatio > 0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry.isIntersecting || entry.intersectionRatio > 0 disjunction matches what Next.js does in links.ts for browser compatibility. Good.


useLayoutEffect(() => {
setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements));
window.__VINEXT_PING_VISIBLE_LINKS__?.();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right place to ping — after setMountedSlotsHeader updates the slot context, visible links will re-prefetch with the correct mounted-slots header. The ping is conditional on treeState.elements changing (the effect dep), which is when slot bindings can change. Clean.

routerMode: LinkPrefetchRouterMode;
}): boolean {
if (input.nodeEnv !== "production" || input.isDangerous) return false;
return input.routerMode === "pages" || input.prefetch !== false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear and correct: Pages Router intent is always allowed (dangerous/dev already filtered), App Router intent respects prefetch !== false. The short-circuit reads naturally.

}
prefetched.clear();
if (!isServer) {
window.__VINEXT_PING_VISIBLE_LINKS__?.();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering here is important and correct: prefetched.clear() runs before pingVisibleLinkPrefetches(), so the dedup guard inside prefetchUrl won't block re-prefetch. Good.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary:

Approved with minor inline comments. The PR correctly aligns vinext's Link prefetch behavior with Next.js by:

  1. Tracking visible App Router links in a Set for ping-based rescheduling after cache invalidation or router-state changes
  2. Splitting intent eligibility by router mode — App Router prefetch={false} blocks both viewport and intent, Pages Router prefetch={false} only blocks viewport (hover/touch still works)
  3. Supporting unstable_dynamicOnHover for upgrading auto-prefetch to full on hover intent
  4. Using a global __VINEXT_PING_VISIBLE_LINKS__ hook to avoid circular imports between link.tsx and navigation.ts

All 84 tests pass. The inline comments are minor suggestions (guard registerVisibleLinkPing with an early return, add a comment about the defensive isVisible check, and a note about unstable_dynamicOnHover instance mode persistence).

github run

@james-elicx james-elicx merged commit 042ac43 into cloudflare:main May 16, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants