Skip to content

fix(Link): restore prefetching under Nuxt 4.5's custom slot - #538

Merged
IgorShevchik merged 1 commit into
mainfrom
sync/nuxt-c4ee0ea3
Sep 5, 2026
Merged

fix(Link): restore prefetching under Nuxt 4.5's custom slot#538
IgorShevchik merged 1 commit into
mainfrom
sync/nuxt-c4ee0ea3

Conversation

@IgorShevchik

Copy link
Copy Markdown
Collaborator

Ports c4ee0ea3 and fbb9e220 — a contiguous pair taken in one PR per §6 4b; the second is n/a and carries no change. Cursor moves to fbb9e220, which is nuxt/ui v4 HEAD, so the sync is caught up.

The regression was real here, and read from the installed source

Nuxt 4.5 stopped prefetching custom links itself. Rather than trust the upstream subject, this was checked against nuxt-link.js in the nuxt@4.5.2 this fork declares:

line guard
:225 if (import.meta.client && !props.custom) — the visibility observer
:297 if (!props.custom) { … shouldPrefetch('interaction') … } — hover and focus

Link.vue here 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 :210 computes props.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.ts is taken as upstream wrote it — a requestIdleCallback compat pair and one shared IntersectionObserver. Its header note holds here unchanged: kept out of utils/link.ts so the Vue builds, which share LinkBase and never render NuxtLink, do not load it.

Link.vue takes the wiring with the fork's standing renames only (b24ui, B24LinkBase). Our resolveLinkClass keeps its extra isAction variant, so prefetchedClass joins the existing class rather than replacing upstream's shorter call. Both template branches are wired, as upstream does.

A guard of ours that upstream does not have

jsdoc-coverage rejected 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 cancelIdleCallback is 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:prefetch hook on the Nuxt app rather than against markup, and drive the IntersectionObserver by hand (happy-dom's is inert). Five cases: both triggers, the preservation of a caller's own pointerenter / focus handlers, and the two opt-outs.

Mutation-checked four ways, each red on a different subset:

mutation red
remove both getPrefetchListeners spreads — revert the port 3 of 5
disable the visibility observer 1
drop the caller's listeners from mergeProps 1
ignore shouldPrefetch('interaction') 1

fbb9e220 — n/a

Four 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 and CodeTree is not among them — checked by listing the directory rather than grepping the name, so a rename would have shown up. Nothing in src/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 2799fa6f and fixed later in #520.

Verification

lint · typecheck · build (3.87 MB, up from 3.86 — the new module is visible in the size) · test343 files, 7847 passed, 6 skipped · test:module.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb


Generated by Claude Code

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
IgorShevchik merged commit 941e9f2 into main Sep 5, 2026
2 checks passed
@IgorShevchik
IgorShevchik deleted the sync/nuxt-c4ee0ea3 branch September 5, 2026 04:36
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>
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