Skip to content

fix(Link): wait for onNuxtReady before observing visibility - #541

Merged
IgorShevchik merged 1 commit into
mainfrom
sync/nuxt-612ab1cb
Sep 6, 2026
Merged

fix(Link): wait for onNuxtReady before observing visibility#541
IgorShevchik merged 1 commit into
mainfrom
sync/nuxt-612ab1cb

Conversation

@IgorShevchik

Copy link
Copy Markdown
Collaborator

Ports 612ab1cb. Cursor moves to it, which is nuxt/ui v4 HEAD.

This corrects a defect in #538, merged an hour ago. Upstream's 612ab1cb is a follow-up to the c4ee0ea3 that PR carried, so this fork shipped the defect too — for the length of one PR.

The defect

onMounted runs before hydration finishes. Two facts about Nuxt combine badly there:

  • the payload plugin registers its link:prefetch listener onNuxtReady;
  • prefetch() sets prefetched before dispatching.

Fire it early and both halves fail at once: nobody is listening, so nothing is fetched — and the link is marked prefetched, so shouldPrefetch refuses every later attempt.

So it is not "prefetching happens sooner than ideal". The link is retired without ever having fetched anything — the same silent shape as the regression c4ee0ea3 was written to fix, arriving from the other side.

The fix

Registration moves inside onNuxtReady. 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.

Verbatim — our pre-image is upstream's line for line, being its own code taken a day earlier. The comment gains one clause, because "wait for hydration" does not convey that firing early retires the link.

The tests do not cover this, and that was measured

mutation result
onNuxtReady(cb) → call cb immediately (i.e. revert to what #538 shipped) 5 passed
remove the unmounted guard 5 passed

Upstream added no test either. Two reasons, the first measured rather than guessed:

The deferral is real but invisible. Instrumenting the callback order in this environment gives before -> after -> callback, so onNuxtReady genuinely defers — but the spec's idle() helper waits 10 ms, which covers both the deferral and the idle callback. The ordering changes; the outcome inside that window does not.

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, with useNuxtApp().hooks.hook('link:prefetch', spy). A listener that is always present cannot be missed.

Taken anyway: the reasoning is checkable in the source, and the change costs nothing. Coverage is not claimed.

This is the third entry in the ledger with that shape, after 6caa6a95 (#527) and this commit's own parent c4ee0ea3 (#538) — where the tests did see the port, through four mutations. The difference each time is whether the assertion can reach the path, not how carefully the test is written.

Verification

lint · typecheck · build (3.87 MB) · test343 files, 7847 passed, 6 skipped · test:module.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb


Generated by Claude Code

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.

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 89f81a7 into main Sep 6, 2026
2 checks passed
@IgorShevchik
IgorShevchik deleted the sync/nuxt-612ab1cb branch September 6, 2026 03:24
IgorShevchik added a commit that referenced this pull request Sep 6, 2026
`612ab1cb` went in with `pending-merge` in both `pr` and `b24ui_sha`, because
the entry is written in the same commit as the port, before its PR has a number
or a squash SHA. That PR has now merged:

- `612ab1cb` → #541 / `89f81a79`

Bookkeeping only — no entry is added, the cursor does not move, and no decision
changes. The cursor stays at `612ab1cb`, which is nuxt/ui v4 HEAD, so the sync
is caught up and no entry carries the placeholder.


Claude-Session: https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb

Co-authored-by: Shevchik Igor <noreply@anthropic.com>
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>
IgorShevchik added a commit that referenced this pull request Sep 7, 2026
`pnpm repl:build` has been failing on `main` since #541, taking the Pages
deploy with it — three commits, while `ci` stayed green the whole time.

rolldown reports it as:

    [MISSING_EXPORT] "onNuxtReady" is not exported by
    "../../dist/runtime/vue/stubs/none.js"

`Link.vue` imports `onNuxtReady` from `#imports` unconditionally and calls it
only behind `prefetchApi`, which is `undefined` without `NuxtLink` — so no Vue
build ever runs it. An import that is never called still has to resolve, and
rolldown fails the whole bundle rather than warning.

The stub runs the callback at the next idle moment and does nothing on the
server. Nuxt's own waits for hydration and then for idle; a plain Vue app has
no hydration to wait for, so only the idle half is left, and that is the half
callers want.

Why `ci` could not see it: `#imports` is a Nuxt alias, so `typecheck`, `test`
and `build` all resolve it against Nuxt's generated types, where the name is
present. The stubs are consulted only by a bundle built *without* Nuxt, which
here is `pnpm repl:build` — a step in `deploy.yml` and in no other workflow.

`test/utils/vue-stub-imports.spec.ts` closes that gap at the level where it is
cheap: it reads every `import { … } from '#imports'` under `src/runtime` as
text and asserts all three stub entry points cover the names. Mutation-checked
— removing this export, dropping `export * from './base'`, and renaming one
inherited export each turn it red, and the first names `Link.vue` as the caller.


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