fix(Link): restore prefetching under Nuxt 4.5's custom slot - #538
Merged
Conversation
Ports `c4ee0ea3` and `fbb9e220` — a contiguous pair taken in one PR per
PORTING.md §6 4b. The second is n/a and carries no change.
Nuxt 4.5 stopped prefetching `custom` links itself, and the regression here was
established from the installed source rather than a changelog: `nuxt-link.js` in
`nuxt@4.5.2` guards the visibility observer at :225 and the hover/focus handlers
at :297, both behind `!props.custom`. This component always renders
`<NuxtLink … custom>`, so neither fired — nothing errored and nothing warned,
navigations were simply slower than they had been. The same file exposes
`prefetched` and `shouldPrefetch` to the slot at :284, which makes this a
re-wiring rather than a reimplementation.
`src/runtime/utils/prefetch.ts` is upstream's, kept out of `utils/link.ts` so the
Vue builds — which share `LinkBase` and never render `NuxtLink` — do not load it.
Its three exports gained JSDoc blocks: upstream documents two of them with line
comments and the third not at all, which `jsdoc-coverage` rejects. That guard
caught it, and writing the blocks pinned down something the line comments left
implicit — a missing handle in `cancelIdleCallback` is a no-op, so an unmount
before the callback was scheduled needs no guard at the call site.
Both template branches are wired, as upstream does: the `custom` slot where the
consumer renders the element, and the `B24LinkBase` branch where we do.
The tests can see this port, which is worth saying next to `6caa6a95` earlier in
this queue, where the equivalent handler is never invoked under happy-dom. These
assert against the `link:prefetch` hook and drive the `IntersectionObserver` by
hand. Four mutations, each red on a different subset: reverting the port (3 of
5), disabling the visibility observer (1), dropping the caller's own listeners
from `mergeProps` (1), ignoring `shouldPrefetch('interaction')` (1).
`fbb9e220` is n/a: `src/runtime/components/prose/` holds 43 components and
`CodeTree` is not among them — checked by listing the directory, so a rename
would have shown up. Recorded in its log rather than acted on: this fork has 43
prose components and no playground page for any of them, while upstream now has
one. That is a gap of ours, not a port.
Gate: lint, typecheck, build (3.87 MB), test (343 files, 7847 passed),
test:module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb
IgorShevchik
added a commit
that referenced
this pull request
Sep 5, 2026
The four entries written during this run carried `pending-merge` in both `pr` and `b24ui_sha`, because the entry goes in with the port before its PR has a number or a squash SHA. Both PRs have now merged: - `bbf1e149`, `90c45a66` → #537 / `2227a69e` - `c4ee0ea3`, `fbb9e220` → #538 / `941e9f2c` Bookkeeping only — no entry is added, the cursor does not move, and no decision changes. The cursor is `fbb9e220`, which is nuxt/ui v4 HEAD, so the sync is caught up and no entry carries the placeholder.
IgorShevchik
added a commit
that referenced
this pull request
Sep 6, 2026
Ports `612ab1cb`, a follow-up to `c4ee0ea3` which this fork took one day earlier in #538. It corrects a defect in that fix rather than adding anything, so we shipped the defect too, for the length of one PR. `onMounted` runs before hydration finishes. Nuxt's payload plugin registers its `link:prefetch` listener `onNuxtReady`, and `prefetch()` sets `prefetched` before dispatching. Fire it early and both halves fail together: nobody is listening, so nothing is fetched, and the link is marked prefetched, so `shouldPrefetch` refuses every later attempt. Not "prefetching happens sooner than ideal" — the link is retired without ever having fetched anything, which is the same silent shape as the regression `c4ee0ea3` fixed, arriving from the other side. Registration moves inside `onNuxtReady`, and an `unmounted` flag stops a component torn down before hydration from registering an observer afterwards — `onBeforeUnmount` cannot cancel an idle callback that was never scheduled. The tests do not cover this, measured rather than assumed. Replacing `onNuxtReady` with an immediate call — reverting to what #538 shipped — leaves all five green, and so does removing the `unmounted` guard; upstream added no test either. Instrumenting the callback order gives `before -> after -> callback`, so `onNuxtReady` does defer here, but the spec's `idle()` helper waits 10ms and covers both the deferral and the idle callback. Underneath that the defect cannot reproduce in this spec at all: it is about prefetching before the payload plugin has registered its listener, and the spec registers the listener by hand before mounting. A listener that is always present cannot be missed. Taken anyway — the reasoning is checkable in the source and the change costs nothing — with coverage not claimed.
IgorShevchik
added a commit
that referenced
this pull request
Sep 7, 2026
…ut (#543) Port of nuxt/ui@8a25c22b, third in the `Link` prefetch chain after `c4ee0ea3` (#538) and `612ab1cb` (#541), and a consequence of the second: `onNuxtReady` put a second deferral in front of the idle callback, so the spec's flat 10 ms wait sat in front of a two-step chain while still doing a non-null `instances[0]!`. Applies verbatim — the file was byte-identical to upstream's pre-image. The measurement cuts against the commit subject: registration completes in 2 ms here, three runs of three, so the old 10 ms had a 5x margin and nothing was failing. This guards a loaded CI runner rather than repairing a break. The real gain is an assertion, not a timeout. `instances[0]!` reads the first observer and ignores any others; `waitForObserver` asserts `toHaveLength(1)`, and `observeIntersection` shares one `IntersectionObserver` across every link — so "exactly one" is the invariant that sharing holds, and nothing asserted it before. Constructing a second observer per call turns 1 red on the port and leaves 5 passed on the pre-image. Claude-Session: https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb Co-authored-by: Shevchik Igor <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports
c4ee0ea3andfbb9e220— a contiguous pair taken in one PR per §6 4b; the second is n/a and carries no change. Cursor moves tofbb9e220, which isnuxt/uiv4HEAD, so the sync is caught up.The regression was real here, and read from the installed source
Nuxt 4.5 stopped prefetching
customlinks itself. Rather than trust the upstream subject, this was checked againstnuxt-link.jsin thenuxt@4.5.2this fork declares::225if (import.meta.client && !props.custom)— the visibility observer:297if (!props.custom) { … shouldPrefetch('interaction') … }— hover and focusLink.vuehere always renders<NuxtLink … custom>, so neither fired. Nothing errors and nothing warns; navigations are simply slower than they were — the quietest kind of regression.The same file exposes the replacement API to the slot at
:284(prefetched,shouldPrefetch), which is what makes this a re-wiring rather than a reimplementation. And:210computesprops.custom ? ref?.$el?.nextElementSibling : ref?.$el— the origin of the "root is a fragment anchor, the element is its next sibling" step in the port.The port
src/runtime/utils/prefetch.tsis taken as upstream wrote it — arequestIdleCallbackcompat pair and one sharedIntersectionObserver. Its header note holds here unchanged: kept out ofutils/link.tsso the Vue builds, which shareLinkBaseand never renderNuxtLink, do not load it.Link.vuetakes the wiring with the fork's standing renames only (b24ui,B24LinkBase). OurresolveLinkClasskeeps its extraisActionvariant, soprefetchedClassjoins the existingclassrather than replacing upstream's shorter call. Both template branches are wired, as upstream does.A guard of ours that upstream does not have
jsdoc-coveragerejected the new module: upstream documents two of its three exports with line comments and the third not at all, while our rule (#89) wants a/** */block over every export from a published path.Writing them pinned down something the line comments left implicit — a missing handle in
cancelIdleCallbackis a no-op, so an unmount before the callback was ever scheduled needs no guard at the call site.The tests can see this port
Worth saying explicitly, because #527 earlier in this same queue was the opposite case — there the ported handler is never invoked under happy-dom and its tests stay green with or without the fix.
These assert against the
link:prefetchhook on the Nuxt app rather than against markup, and drive theIntersectionObserverby hand (happy-dom's is inert). Five cases: both triggers, the preservation of a caller's ownpointerenter/focushandlers, and the two opt-outs.Mutation-checked four ways, each red on a different subset:
getPrefetchListenersspreads — revert the portmergePropsshouldPrefetch('interaction')fbb9e220— n/aFour files, all
CodeTree: the fix, 169 lines of test, a new 391-line playground page added as the surface for it, and one line registering that page.src/runtime/components/prose/here holds 43 components andCodeTreeis not among them — checked by listing the directory rather than grepping the name, so a rename would have shown up. Nothing insrc/theme/references it either.Recorded, not acted on: this fork has 43 prose components and no playground page for any of them, while upstream now has one. That is a gap of ours rather than a port — building it would mean writing our own page for our own components. Written into the log and left, the same shape as the InputMenu playground row found while backfilling
2799fa6fand fixed later in #520.Verification
lint·typecheck·build(3.87 MB, up from 3.86 — the new module is visible in the size) ·test— 343 files, 7847 passed, 6 skipped ·test:module.🤖 Generated with Claude Code
https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb
Generated by Claude Code