mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
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:
co-authored by
Claude Opus 5.5
parent
9ee6718ad1
commit
58eb57a324
@@ -121,6 +121,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- `codegraph sync` and the file watcher now also link the imports of an added or restored JavaScript or TypeScript file whose name hundreds of other imports share, like a project's own `utils.ts` among `jotai/utils` and `@scope/pkg/utils` imports, or `assert.ts` in a project full of `import * as assert from 'assert'`. Before, a name that common was skipped as noise no single file could answer, so in a large project the file's own `./utils` and `~/settings/utils` imports stayed unlinked until the importing files changed or the project was re-indexed.
|
||||
- `codegraph sync` and the file watcher now link an import of a folder two or more levels up, like `import { Excalidraw } from "../.."` in excalidraw's tests, to that folder's `index` file when the file is added after the importing file was indexed, or comes back after being deleted. Before, only an import of the file's own folder or the one above (`.`, `..`) was relinked; one reaching further up stayed unlinked until the importing file changed or the project was re-indexed.
|
||||
- `codegraph sync` and the file watcher now link a navigation call to its page when the page or route is added after the call was indexed, or comes back after being removed: `history.push('/login')` and `navigate('/login')` with React Router, `router.push('/login')` and `redirect('/login')` in Next.js, Expo Router, Vue Router, TanStack Router and SvelteKit (`goto('/login')`), and `this.router.navigate(['/login'])` and `routerLink="/login"` in Angular. Before, the call stayed unlinked, or linked to a catch-all or `:id` page that answered it in the meantime, until its own file changed or the project was re-indexed, so the step from that call to the page it opens was missing or wrong. A route whose path changes without its own file changing, like one built from a path constant kept in another file, now also lets go of the calls that named its old path. Re-index after upgrading.
|
||||
- In Nuxt, a `navigateTo('/login')` call now links to the page it opens, as `router.push('/login')` does, whether it is written in a page, a component, a composable or route middleware. Before, every `navigateTo` call was recorded as the calling code calling itself, so a flow through a Nuxt redirect stopped there. A call whose destination is computed, names no page, or leaves the app (`{ external: true }`) is left unlinked, never tied to a project function that only shares the name. Re-index Nuxt projects after upgrading. Thanks @naashw for the report. (#2634)
|
||||
- JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way.
|
||||
- In a repository that holds more than one Shopify theme, a theme's `{% render %}`, `{% include %}` and `{% section %}` tags and the sections its JSON templates list now link only to that theme's own snippets and sections, which is where Shopify looks for them. Before, a section or snippet the theme doesn't have was linked to another theme's file of the same name, and a theme kept in a subfolder could link to the root theme's copy instead of its own. A reference the theme can't satisfy now links to nothing. Projects with a single theme are indexed as before. Re-index projects with several Shopify themes after upgrading.
|
||||
- `codegraph sync` and the file watcher now link a Shopify section or snippet to the files that name it when it is added after them, or comes back after being deleted, whether they name it in a `{% render %}`, `{% include %}` or `{% section %}` tag or as a section `type` in a JSON template. Before, the link appeared only once a file naming it changed or the project was re-indexed, even in a repository with a single theme, so the new section or snippet looked unused and `codegraph affected` missed the files that use it. Re-index Shopify themes after upgrading to pick up links an earlier version missed.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Nuxt's `navigateTo('/x')` navigates to the page at `/x`, as `router.push`
|
||||
* does (#2634).
|
||||
*
|
||||
* `navigateTo` was in the Vue resolver's list of Nuxt auto-imports, which
|
||||
* answers each one with a sink, an edge from the calling node to itself. The
|
||||
* Vue resolver runs before Vue Router's, so the navigation was never read:
|
||||
* every `navigateTo` became a self-pointing `calls` edge, in pages and in
|
||||
* route middleware alike, and no `navigates` edge was drawn.
|
||||
*
|
||||
* Now Vue Router reads the destination, and Nuxt owns what it can't read: a
|
||||
* computed destination, a path no page serves or an external URL calls Nuxt's
|
||||
* `navigateTo` all the same, so it is left unresolved rather than linked to a
|
||||
* project function that only shares the name (elk declares one inside a
|
||||
* plugin). A file that declares or imports its own `navigateTo` calls that one.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import CodeGraph from '../src/index';
|
||||
import type { Edge, Node } from '../src/types';
|
||||
|
||||
const NUXT_APP: Record<string, string> = {
|
||||
'package.json': JSON.stringify({ name: 'site', private: true, dependencies: { nuxt: '^4.0.0', vue: '^3.5.0' } }),
|
||||
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '~/*': ['./*'] } } }),
|
||||
// The issue's pages.
|
||||
'pages/index.vue': "<script setup lang=\"ts\">\nawait navigateTo('/target');\n</script>\n<template><div /></template>\n",
|
||||
'pages/push.vue':
|
||||
'<script setup lang="ts">\nconst router = useRouter();\nfunction go() { router.push(\'/target\'); }\n</script>\n' +
|
||||
'<template><button @click="go" /></template>\n',
|
||||
'pages/target.vue': '<template><div /></template>\n',
|
||||
'pages/login.vue': '<template><div>login</div></template>\n',
|
||||
'middleware/auth.global.ts':
|
||||
"export default defineNuxtRouteMiddleware((to) => {\n if (to.path !== '/login') {\n return navigateTo('/login');\n }\n});\n",
|
||||
'pages/account.vue':
|
||||
'<script setup lang="ts">\n' +
|
||||
'const props = defineProps<{ next: string }>();\n' +
|
||||
"function goLogin() { return navigateTo({ path: '/login', query: { from: 'account' } }); }\n" +
|
||||
'function goNext() { return navigateTo(props.next); }\n' +
|
||||
"function goMissing() { return navigateTo('/nowhere'); }\n" +
|
||||
"function goDocs() { return navigateTo('https://nuxt.com/docs', { external: true }); }\n" +
|
||||
'</script>\n' +
|
||||
'<template>\n <button @click="goLogin" />\n <a @click="navigateTo(\'/target\')">Back</a>\n</template>\n',
|
||||
'composables/useNav.ts': 'export function useNav() {\n return { go: (to: string) => navigateTo(to) };\n}\n',
|
||||
// Two project functions named navigateTo: a plugin's own, and a helper's.
|
||||
'plugins/keys.client.ts':
|
||||
'export default defineNuxtPlugin(() => {\n' +
|
||||
' const navigateTo = (to: string) => {\n console.log(to);\n };\n' +
|
||||
" window.addEventListener('keydown', () => navigateTo('/target'));\n" +
|
||||
'});\n',
|
||||
'lib/open.ts': 'export function navigateTo(url: string) {\n return window.open(url);\n}\n',
|
||||
'pages/docs.vue':
|
||||
"<script setup lang=\"ts\">\nimport { navigateTo } from '~/lib/open';\nfunction openDocs(url: string) { return navigateTo(url); }\n</script>\n" +
|
||||
'<template><button @click="openDocs" /></template>\n',
|
||||
};
|
||||
|
||||
const roots: string[] = [];
|
||||
function indexProject(files: Record<string, string>): Promise<CodeGraph> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nuxt-nav-'));
|
||||
roots.push(root);
|
||||
for (const [file, text] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, file), text);
|
||||
}
|
||||
return CodeGraph.init(root, { index: true });
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Nuxt navigateTo', () => {
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
cg = await indexProject(NUXT_APP);
|
||||
});
|
||||
afterAll(() => cg?.close());
|
||||
|
||||
const route = (name: string): Node => cg.getNodesByKind('route').find((r) => r.name === name)!;
|
||||
const sym = (name: string, file: string): Node => cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import')!;
|
||||
/** The edges a node makes from its `navigateTo` calls. */
|
||||
const navigateToEdges = (from: Node): Edge[] =>
|
||||
cg.getOutgoingEdges(from.id).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.refName === 'navigateTo');
|
||||
const destinations = (from: Node) =>
|
||||
navigateToEdges(from).map((e) => `${e.kind} ${(e.metadata as Record<string, unknown>).href} -> ${cg.getNode(e.target)?.name}`);
|
||||
|
||||
it('navigates from a page to the page it names, with no edge from the page to itself', () => {
|
||||
expect(destinations(sym('index', 'pages/index.vue'))).toEqual(['navigates /target -> /target']);
|
||||
expect(navigateToEdges(sym('index', 'pages/index.vue'))[0]!.target).toBe(route('/target').id);
|
||||
// router.push in the same app, unchanged.
|
||||
expect(cg.getOutgoingEdges(sym('go', 'pages/push.vue').id).filter((e) => e.kind === 'navigates').map((e) => e.target)).toEqual([
|
||||
route('/target').id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('navigates from route middleware', () => {
|
||||
const file = cg.getNodesInFile('middleware/auth.global.ts').find((n) => n.kind === 'file')!;
|
||||
expect(destinations(file)).toEqual(['navigates /login -> /login']);
|
||||
});
|
||||
|
||||
it('reads an object destination and a call written in the template', () => {
|
||||
expect(destinations(sym('goLogin', 'pages/account.vue'))).toEqual(['navigates /login -> /login']);
|
||||
expect(destinations(sym('account', 'pages/account.vue'))).toEqual(['navigates /target -> /target']);
|
||||
});
|
||||
|
||||
it('leaves a computed destination, a path no page serves and an external URL unresolved', () => {
|
||||
for (const [name, file] of [
|
||||
['goNext', 'pages/account.vue'],
|
||||
['goMissing', 'pages/account.vue'],
|
||||
['goDocs', 'pages/account.vue'],
|
||||
['useNav', 'composables/useNav.ts'],
|
||||
] as const) {
|
||||
expect(navigateToEdges(sym(name, file)), name).toEqual([]);
|
||||
}
|
||||
// Neither project function named navigateTo is called by any of them, nor
|
||||
// does any navigateTo call point at its own caller.
|
||||
const namesakes = cg.getNodesByName('navigateTo').map((n) => n.id);
|
||||
expect(namesakes).toHaveLength(2);
|
||||
const intoNamesakes = cg.getIncomingEdgesTo(namesakes, ['calls']);
|
||||
expect(intoNamesakes.map((e) => `${cg.getNode(e.source)?.name} -> ${cg.getNode(e.target)?.filePath}`)).toEqual([
|
||||
'openDocs -> lib/open.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls the navigateTo a file imports, and navigates through the one a file declares', () => {
|
||||
const docs = cg.getOutgoingEdges(sym('openDocs', 'pages/docs.vue').id).filter((e) => e.kind === 'calls');
|
||||
expect(docs.map((e) => cg.getNode(e.target)).map((n) => `${n?.name} ${n?.filePath}`)).toEqual(['navigateTo lib/open.ts']);
|
||||
// The plugin's wrapper is called with a page's path: where it goes, as before.
|
||||
const plugin = cg.getNodesInFile('plugins/keys.client.ts').find((n) => n.kind === 'file')!;
|
||||
expect(destinations(plugin)).toEqual(['navigates /target -> /target']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigateTo outside Nuxt', () => {
|
||||
it('is the project function a plain Vue app imports', async () => {
|
||||
const cg = await indexProject({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.5.0', 'vue-router': '^4.4.0' } }),
|
||||
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }),
|
||||
'src/router/index.ts':
|
||||
"import { createRouter, createWebHistory } from 'vue-router';\n" +
|
||||
"export default createRouter({ history: createWebHistory(), routes: [{ path: '/login', component: () => import('@/views/Login.vue') }] });\n",
|
||||
'src/views/Login.vue': '<template><div>login</div></template>\n',
|
||||
'src/utils/nav.ts': 'export function navigateTo(path: string) {\n window.location.assign(path);\n}\n',
|
||||
'src/views/Home.vue':
|
||||
"<script setup lang=\"ts\">\nimport { navigateTo } from '@/utils/nav';\nfunction go(path: string) { navigateTo(path); }\n</script>\n" +
|
||||
'<template><button @click="go(\'/login\')" /></template>\n',
|
||||
});
|
||||
try {
|
||||
const go = cg.getNodesInFile('src/views/Home.vue').find((n) => n.name === 'go')!;
|
||||
const calls = cg.getOutgoingEdges(go.id).filter((e) => e.kind === 'calls');
|
||||
expect(calls.map((e) => cg.getNode(e.target)?.filePath)).toEqual(['src/utils/nav.ts']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,24 @@ const CASES: Case[] = [
|
||||
from: 'src/views/Home.vue',
|
||||
expected: '/login → /login',
|
||||
},
|
||||
{
|
||||
// Nuxt's auto-imported navigateTo, waiting for its page (#2634).
|
||||
name: 'Nuxt (navigateTo)',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'site', dependencies: { nuxt: '4', vue: '3' } }),
|
||||
'pages/index.vue':
|
||||
'<template>\n <button @click="signIn">Sign in</button>\n</template>\n' +
|
||||
'<script setup lang="ts">\n' +
|
||||
'function signIn() {\n' +
|
||||
' return navigateTo("/login")\n' +
|
||||
'}\n' +
|
||||
'</script>\n',
|
||||
'pages/login.vue': '<template>\n <div>Login</div>\n</template>\n',
|
||||
},
|
||||
route: { file: 'pages/login.vue', before: null },
|
||||
from: 'pages/index.vue',
|
||||
expected: '/login → /login',
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
files: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user