fix(Link): wait for onNuxtReady before observing visibility - #541
Merged
Conversation
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
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>
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
612ab1cb. Cursor moves to it, which isnuxt/uiv4HEAD.This corrects a defect in #538, merged an hour ago. Upstream's
612ab1cbis a follow-up to thec4ee0ea3that PR carried, so this fork shipped the defect too — for the length of one PR.The defect
onMountedruns before hydration finishes. Two facts about Nuxt combine badly there:link:prefetchlisteneronNuxtReady;prefetch()setsprefetchedbefore dispatching.Fire it early and both halves fail at once: nobody is listening, so nothing is fetched — and the link is marked prefetched, so
shouldPrefetchrefuses 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
c4ee0ea3was written to fix, arriving from the other side.The fix
Registration moves inside
onNuxtReady. Anunmountedflag stops a component torn down before hydration from registering an observer afterwards:onBeforeUnmountcannot 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
onNuxtReady(cb)→ callcbimmediately (i.e. revert to what #538 shipped)unmountedguardUpstream 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, soonNuxtReadygenuinely defers — but the spec'sidle()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 parentc4ee0ea3(#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) ·test— 343 files, 7847 passed, 6 skipped ·test:module.🤖 Generated with Claude Code
https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb
Generated by Claude Code