Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
157 changes: 157 additions & 0 deletions __tests__/nuxt-navigate-to.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
18 changes: 18 additions & 0 deletions __tests__/sync-navigation-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading