fix(nuxt): navigateTo('/x') links to the page it opens, as router.push does (#2826)

Nuxt's navigateTo was in the Vue resolver's list of Nuxt auto-imports,
which answers each with a sink: an edge from the calling node to itself
at confidence 1. The Vue resolver runs before Vue Router's, so the call's
destination was never read. Every navigateTo, in pages and route
middleware alike, became a self-pointing calls edge, and no navigates
edge was drawn; the sink also kept sync from revisiting the call when a
page appeared (a navigation waits as a failed reference).

Fix:
- navigateTo leaves the auto-import list, so Vue Router reads it.
- A new optional hook, FrameworkResolver.ownsReference: a reference a
  framework answers alone stops at the frameworks' answer, and no import
  or name strategy guesses past it. nuxtResolver owns a bare navigateTo
  that no declaration or import in the file binds. One whose destination
  is computed, names no page or is an external URL stays unresolved,
  rather than linked to a project function that only shares the name
  (elk declares a navigateTo of its own inside a plugin). A file that
  declares or imports its own navigateTo calls that one, as before.
- Vue Router's call reader keeps only destinations that start with "/",
  as its markup reader does: navigateTo(url, { external: true }) leaves
  the app, and the shared reader had matched an absolute URL's path.
- framework-coverage.md: the Vue Router / Nuxt row and a new trap 21.

Validation:
- nuxt-navigate-to.test.ts (6 tests) and a Nuxt case in
  sync-navigation-retry.test.ts (the page appears later; sync equals a
  fresh index). All 7 fail on main. Without the ownership check the
  unresolved case fails (a composable's call reaches a namesake); without
  the "/" rule the external URL navigates to "/".
- A/B against main 9ee6718a (and before that 628f69e2, with the same
  deltas), kernel on, natural-key dumps and a site-keyed edge diff. Every
  changed edge is a navigateTo call site; no node changed. Across
  atidone, elk, nuxt.com, mealie and nuxt/ui, 54 navigateTo calls
  (elk's plugin with its own navigateTo aside, unchanged) lost their
  self-loop; 41 now navigate (42 edges, each checked against its source
  line, all correct) and 13 are unresolved: 6 computed, an API endpoint
  opened with external: true, 4 pages of a Nuxt layer (its own route
  table), and 2 pages the matcher can't read yet (a catch-all and an
  optional parameter).
- Byte-identical: nuxt/movies; vue-vben-admin and vue-element-plus-admin,
  plain Vue apps that declare their own navigateTo; the controls
  svelte-realworld (Svelte) and gin (Go).
- Index time unchanged within noise, medians of interleaved runs, main
  then fix: nuxt.com 5.21 s and 5.24 s (5 runs), mealie 11.52 s and
  11.60 s, elk 3.38 s and 3.55 s (3 runs).

Fixes #2634

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 04:01:39 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9ee6718ad1
commit 58eb57a324
8 changed files with 238 additions and 5 deletions
+4 -1
View File
@@ -535,7 +535,10 @@ export const vueRouterResolver: FrameworkResolver = {
if (!href) return null;
// Every arm of a conditional destination is somewhere this call goes; the
// first is this reference's resolution and the rest ride as `alsoTargets`.
const targets = destinationsForHref(href, routes);
// An arm that is no path of the app — an absolute URL, which Nuxt's
// `navigateTo(url, { external: true })` leaves the app for — opens none of
// its routes, as `<NuxtLink to>` is read (vue-router-synthesizer).
const targets = destinationsForHref(href, routes).filter((t) => t.href.path.startsWith('/'));
const target = targets[0];
if (!target) return null;
return {
+23 -2
View File
@@ -28,13 +28,15 @@ const VUE_COMPILER_MACROS = new Set([
]);
/**
* Nuxt auto-imported composables and utilities
* Nuxt auto-imported composables and utilities. `navigateTo` is not one of
* them here: `navigateTo('/x')` is a navigation, which Vue Router's resolver
* links to the page it opens, and Nuxt owns the call when it can't (see
* `nuxtResolver.ownsReference`, #2634).
*/
const NUXT_AUTO_IMPORTS = new Set([
// Routing
'useRoute',
'useRouter',
'navigateTo',
'abortNavigation',
// Data fetching
'useFetch',
@@ -213,6 +215,25 @@ export const nuxtResolver: FrameworkResolver = {
return resolvePageComponent(ref, context);
},
/**
* Nuxt's auto-imported `navigateTo` (#2634). Vue Router's resolver links a
* call to the page it opens; one it can't link — a computed destination, a
* path no page serves, an external URL — still calls Nuxt's, never a
* project function that shares the name, such as the `navigateTo` elk
* declares inside a plugin. A file that declares or imports a `navigateTo`
* calls that one, which the other strategies find (an import from `#app`
* links nothing, as any package's does).
*/
ownsReference(ref: UnresolvedRef, context: ResolutionContext): boolean {
return (
ref.referenceKind === 'calls' &&
ref.referenceName === 'navigateTo' &&
VUE_SCRIPT_LANGUAGES.has(ref.language) &&
!declaresOwnFunction(ref, context) &&
!context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName)
);
},
extract(filePath: string, _content: string) {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
+8
View File
@@ -1713,6 +1713,14 @@ export class ReferenceResolver {
}
if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw);
if (fwEarly) return fwEarly;
// A reference a framework answers alone ends with the frameworks' answer
// (see FrameworkResolver.ownsReference): Nuxt's `navigateTo(x)` with a
// destination no route serves is no namesake's call.
if (this.frameworksFor(ref.language).some((f) => f.ownsReference?.(ref, this.context))) {
return candidates.length > 0
? candidates.reduce((best, curr) => (curr.confidence > best.confidence ? curr : best))
: null;
}
// A retained untyped chain supplies effect/call-site evidence only. In
// particular, importing its root does not make the root its call target.
// A path through module namespaces is not untyped: `z.coerce.number()`
+11
View File
@@ -373,6 +373,17 @@ export interface FrameworkResolver {
* Asked only about references written in the languages `resolve()` sees.
*/
claimsReference?(name: string): boolean;
/**
* A reference this framework answers alone. When no framework's `resolve()`
* links it, it stays unresolved: no import or name strategy guesses past it,
* as they would for a project function that only shares its name. Nuxt's
* auto-imported `navigateTo('/x')` names a page, which Vue Router reads;
* one whose destination is computed, or a path no page serves, calls no
* function of the project — elk declares a `navigateTo` of its own inside a
* plugin. Unresolved, a sync can still revisit it when a route appears.
* Asked only about references written in the languages `resolve()` sees.
*/
ownsReference?(ref: UnresolvedRef, context: ResolutionContext): boolean;
/**
* Extract framework-specific nodes and references from a file.
*