diff --git a/CHANGELOG.md b/CHANGELOG.md index 6503fe778..1b3e879b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Imports through a tsconfig or jsconfig path alias now use the config nearest the importing file, so each app in a monorepo resolves its own `@/…` or `~/…` alias. On bulletproof-react and trpc's examples, those imports used to fail or land in a different app. +- React Router data-router routes are named by their full path: nested `children` paths are joined to their parent's, `path: paths.app.root.path` constants are read, and `lazy: () => import('./routes/x')` routes link to the module's component. A guard wrapper like `` no longer stands in for the page. On bulletproof-react, every route now has its real path and page. - In JavaScript, TypeScript and Vue, a call through a name destructured from a composable or custom hook, like `const { getDefaultActivityRoute } = useDefaultActivity()` or `const { t } = useI18n()`, now reaches the function the hook returns. These calls used to resolve to nothing. mealie, elk, halo and excalidraw each gained dozens to over a hundred such links. - In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports. - Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading. diff --git a/__tests__/react-router-nested-lazy.test.ts b/__tests__/react-router-nested-lazy.test.ts new file mode 100644 index 000000000..e47dccdfc --- /dev/null +++ b/__tests__/react-router-nested-lazy.test.ts @@ -0,0 +1,99 @@ +/** + * A data router's nested, lazy, constant-named routes (bulletproof-react): + * - a child's `path` is relative to its parent's — `discussions` under `/app` + * is `/app/discussions`; + * - `lazy: () => import('./routes/x')` renders the module's default export; + * - `path: paths.app.root.path` reads the constant object where it is + * declared — here through a monorepo app's own tsconfig `@/*` alias, which + * now resolves from the tsconfig nearest the importing file; + * - `element: ()` renders AppRoot. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-nested-')); + const app = (rel: string) => `apps/web/${rel}`; + const files: Record = { + 'package.json': JSON.stringify({ name: 'mono', private: true }), + [app('package.json')]: JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }), + [app('tsconfig.json')]: JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }), + [app('src/config/paths.ts')]: `export const paths = { + home: { path: '/', getHref: () => '/' }, + app: { + root: { path: '/app', getHref: () => '/app' }, + discussions: { path: 'discussions', getHref: () => '/app/discussions' }, + }, +} as const; +`, + [app('src/app/router.tsx')]: `import { createBrowserRouter } from 'react-router'; +import { paths } from '@/config/paths'; +import { ProtectedRoute } from '@/lib/auth'; +import AppRoot from './routes/app/root'; + +export const router = createBrowserRouter([ + { path: paths.home.path, lazy: () => import('./routes/landing') }, + { + path: paths.app.root.path, + element: ( + + + + ), + children: [ + { path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') }, + { path: 'settings', element: }, + ], + }, +]); +`, + [app('src/lib/auth.tsx')]: `export function ProtectedRoute({ children }: { children: unknown }) { return children; } +`, + [app('src/app/routes/landing.tsx')]: `const LandingRoute = () => null; +export default LandingRoute; +`, + [app('src/app/routes/app/root.tsx')]: `export default function AppRoot() { return null; } +`, + [app('src/app/routes/app/discussions.tsx')]: `export default function DiscussionsRoute() { return null; } +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const renders = (routeName: string): string[] => { + const route = cg.getNodesByKind('route').find((r) => r.name === routeName); + if (!route) return []; + return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name); +}; + +describe('nested, lazy and constant React Router routes', () => { + it('compose their paths and render their modules', () => { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toEqual(['/', '/app', '/app/discussions', '/app/settings']); + expect(renders('/')).toEqual(['LandingRoute']); + expect(renders('/app')).toEqual(['AppRoot']); + expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']); + }); + + it('resolves an app’s own tsconfig alias', () => { + const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id); + const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath); + expect(imported).toContain('apps/web/src/config/paths.ts'); + expect(imported).toContain('apps/web/src/lib/auth.tsx'); + }); +}); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index fd4c9d7e9..fa5cd56e1 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -548,8 +548,9 @@ describe('react-router: route declaration boundaries (#1348)', () => { } `, extension); expect(result).toEqual({ - paths: ['/dashboard', '/empty', '/legacy', '/long', '/no-element', '/sibling', 'settings'], - bindings: ['/legacy->Settings', '/long->Shell', '/sibling->Settings', 'settings->Settings'], + // A nested route's path is relative to its parent's (`settings` under `/dashboard`). + paths: ['/dashboard', '/dashboard/settings', '/empty', '/legacy', '/long', '/no-element', '/sibling'], + bindings: ['/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'], }); }); @@ -573,8 +574,8 @@ describe('react-router: route declaration boundaries (#1348)', () => { ]); `, extension); expect(result).toEqual({ - paths: ['/', '/long', '/quoted', '/sibling', 'prefs'], - bindings: ['/->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings', 'prefs->DataSettings'], + paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'], + bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], }); }); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 6e123c2f5..514ae1d4f 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,7 +38,7 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``); bulletproof-react's `paths.x.path` constants and `lazy` routes are not read yet | +| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes); its `paths.x.getHref()` links are not read yet | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 85db31eee..c8cd88561 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -28,11 +28,12 @@ * in JavaScript, so the receiver has to name a router — a bare `push` is an * array's, and is never claimed. * - * Known limits, both deliberate: a nested route's path is relative to its - * parent (`` inside ``), and the - * markup scan does not compose that tree, so only an absolute path is a - * destination an href can name; and a splat (`/admin/*`) matches anything, so - * it is never the answer to a concrete href. + * A nested route's path is relative to its parent's (`` + * inside ``, a data router's `children`); the markup + * scan composes that tree, a constant path (`paths.app.root.path`) included, + * and a `lazy: () => import('./routes/x')` route renders that module's default + * export (`frameworks/react.ts`). Known limit, deliberate: a splat + * (`/admin/*`) matches anything, so it is never the answer to a concrete href. */ import type { Language, Node } from '../../types'; @@ -70,13 +71,16 @@ export const reactRouterRoot = appRootFor; * * Its id is a verbatim reconstruction of the node's own fields, which no * other framework's route id is: a server route carries its METHOD - * (`route:file:12:POST:/login`), a file-based page carries no line. + * (`route:file:12:POST:/login`), a file-based page carries no line. A path + * built from a constant keeps the id it was extracted with. */ function isReactRouterRoute(node: Node): boolean { - return ( - (node.language === 'tsx' || node.language === 'jsx') && - node.id === `route:${node.filePath}:${node.startLine}:${node.name}` - ); + const prefix = `route:${node.filePath}:${node.startLine}:`; + if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false; + // Its name, or — for a path built from a constant, renamed after extraction + // — the path as the file wrote it; never a server route's `METHOD:`. + const rest = node.id.slice(prefix.length); + return rest === node.name || (!/^[A-Z]+:/.test(rest) && Boolean(node.signature?.startsWith('route-parts:'))); } /** `:id?` — a parameter React Router serves the route with or without. */ diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index fe60ee17f..4ffd422e1 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -8,6 +8,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; +import { resolveImportPath } from '../import-resolver'; export const reactResolver: FrameworkResolver = { name: 'react', @@ -27,7 +28,16 @@ export const reactResolver: FrameworkResolver = { return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx')); }, + // A data-router `lazy: () => import('./routes/x')` route names a module, not a symbol. + claimsReference(name: string): boolean { + return name.startsWith(LAZY_ROUTE_PREFIX); + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceName.startsWith(LAZY_ROUTE_PREFIX)) { + const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; + } // A component, hook or context the file IMPORTS is the import's: the // package's (`useQuery` from `@tanstack/react-query`, `