fix(react-router): nested, lazy and constant routes; per-app tsconfig aliases (#2165)

- Route tables compose nested paths: a child's `path` is relative to the
  path-bearing routes around it (`children` of a data router, nested
  `<Route>` elements), absolute ones reset. `lazy: () => import('./x')`
  (object and JSX) renders the module's default export (`Component` export
  as fallback) via a claimed `lazy-import:` ref. `path: paths.app.root.path`
  keeps its parts on the node's signature and is named in postExtract from
  the constant's object literal (found through the file's import). The
  route keeps its extracted id; `isReactRouterRoute` accepts it. An element
  wrapped in parentheses or a guard (`<ProtectedRoute>`, `<Suspense>`,
  `*Provider`, `*Guard`) renders the first element inside.
- Import aliases: the tsconfig / jsconfig nearest the importing file that
  declares `paths` is tried before the root's, so each monorepo app resolves
  its own `@/*` / `~/*`.

A/B (edges removed / added): bulletproof-react -43/+367 (0 → 9 named,
linked routes; cross-app imports fixed), react-native-reusables -103/+193,
kit -184/+218, trpc -45/+71 (examples' `~/…` now their own), create-t3-turbo
-1/+13; excalidraw, halo, obytes, realworld byte-identical. #1348's nested
fixtures now expect composed paths (`/dashboard/settings`, `/data/prefs`).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 12:34:11 +00:00
committed by GitHub
parent cf449fdad7
commit 37bcd5c0ae
9 changed files with 358 additions and 31 deletions
+2
View File
@@ -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 `<ProtectedRoute>` 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.
@@ -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: (<ProtectedRoute><AppRoot /></ProtectedRoute>)` 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<string, string> = {
'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: (
<ProtectedRoute>
<AppRoot />
</ProtectedRoute>
),
children: [
{ path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') },
{ path: 'settings', element: <Settings /> },
],
},
]);
`,
[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');
});
});
+5 -4
View File
@@ -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'],
});
});
+1 -1
View File
@@ -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 `<Redirect>`); 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 `<Redirect>`), 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) |
+14 -10
View File
@@ -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 (`<Route path="team">` inside `<Route path="/dashboard">`), 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 (`<Route path="team">`
* inside `<Route path="/dashboard">`, 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. */
+196 -11
View File
@@ -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`, `<Button>` from a UI
// kit), or the module the import names, which import resolution finds.
@@ -109,7 +119,7 @@ export const reactResolver: FrameworkResolver = {
// Read only each opening tag's own attributes, including expression values.
const declarations = scanRouteDeclarations(content, !/\.(?:ts|mts|cts)$/.test(filePath));
for (const { path: routePath, component, at } of declarations) {
for (const { path: routePath, parts, component, lazy, at } of declarations) {
const line = content.slice(0, at).split('\n').length;
const routeNode: Node = {
id: `route:${filePath}:${line}:${routePath}`,
@@ -123,12 +133,15 @@ export const reactResolver: FrameworkResolver = {
endColumn: 0,
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
updatedAt: now,
// A path built from a constant (`paths.app.root.path`) is named in postExtract.
...(parts.some((p) => p.expr) ? { signature: ROUTE_PARTS_PREFIX + JSON.stringify(parts) } : {}),
};
nodes.push(routeNode);
if (component) {
const target = component ?? (lazy ? LAZY_ROUTE_PREFIX + lazy : undefined);
if (target) {
references.push({
fromNodeId: routeNode.id,
referenceName: component,
referenceName: target,
referenceKind: 'references',
line,
column: 0,
@@ -142,29 +155,175 @@ export const reactResolver: FrameworkResolver = {
return { nodes, references };
},
/**
* Name the routes whose path is built from a constant — bulletproof-react's
* `path: paths.app.discussions.path` under `path: paths.app.root.path` is
* `/app/discussions` — by reading the constant's object literal where it is
* declared. Idempotent: the parts ride on the node's signature.
*/
postExtract(context: ResolutionContext): Node[] {
const updates: Node[] = [];
for (const route of context.getNodesByKind('route')) {
if (!route.signature?.startsWith(ROUTE_PARTS_PREFIX)) continue;
let parts: RoutePart[];
try {
parts = JSON.parse(route.signature.slice(ROUTE_PARTS_PREFIX.length)) as RoutePart[];
} catch {
continue;
}
const values = parts.map((p) => p.lit ?? (p.expr ? constantPathValue(p.expr, route.filePath, context) : null));
if (values.some((v) => v === null)) continue;
const name = composeRoutePath(values as string[]);
if (name !== route.name) updates.push({ ...route, name });
}
return updates;
},
};
const LAZY_ROUTE_PREFIX = 'lazy-import:';
const ROUTE_PARTS_PREFIX = 'route-parts:';
/** One segment of a nested route's path: a literal, or a constant's member expression. */
interface RoutePart {
lit?: string;
expr?: string;
}
/** React Router's nesting: a child path is relative unless it starts with `/`. */
function composeRoutePath(parts: string[]): string {
let path = '';
for (const part of parts) {
if (part.startsWith('/')) path = part;
else if (part) path = `${path.replace(/\/+$/, '')}/${part}`;
}
return ('/' + path.replace(/^\/+/, '')).replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1');
}
/**
* The string a member expression like `paths.app.root.path` reads from a
* constant object literal — the constant found through the route file's
* import of its root name, or in the file itself.
*/
function constantPathValue(expr: string, fromFile: string, context: ResolutionContext): string | null {
const [root, ...keys] = expr.split('.');
if (!root || keys.length === 0) return null;
let file = fromFile;
let name = root;
const mapping = context.getImportMappings(fromFile, 'tsx').find((m) => m.localName === root) ??
context.getImportMappings(fromFile, 'typescript').find((m) => m.localName === root);
if (mapping) {
const resolved = resolveImportPath(mapping.source, fromFile, 'typescript', context);
if (!resolved) return null;
file = resolved;
if (mapping.exportedName && mapping.exportedName !== 'default' && mapping.exportedName !== '*') name = mapping.exportedName;
}
const decl = context.getNodesInFile(file).find((n) => n.name === name && (n.kind === 'constant' || n.kind === 'variable'));
if (!decl) return null;
const lines = context.readFile(file)?.split('\n') ?? [];
const text = lines.slice(decl.startLine - 1, decl.endLine).join('\n');
const open = text.indexOf('{', Math.max(0, text.search(new RegExp(`\\b${name}\\b`))));
return open < 0 ? null : readObjectPath(text, open, keys);
}
/** Walk `keys` into the object literal opening at `at`; the string literal at the end, or null. */
function readObjectPath(text: string, at: number, keys: string[]): string | null {
const skipString = (j: number): number => {
const quote = text[j]!;
for (j++; j < text.length && text[j] !== quote; j++) if (text[j] === '\\') j++;
return j + 1;
};
const skipValue = (j: number): number => {
let depth = 0;
for (; j < text.length; j++) {
const ch = text[j]!;
if (ch === '"' || ch === "'" || ch === '`') { j = skipString(j) - 1; continue; }
if (ch === '{' || ch === '[' || ch === '(') depth++;
else if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) return j; depth--; }
else if (ch === ',' && depth === 0) return j;
}
return j;
};
let i = at + 1;
while (i < text.length) {
const m = /^\s*(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:\s*/.exec(text.slice(i));
if (!m) {
const next = skipValue(i);
if (text[next] !== ',') return null;
i = next + 1;
continue;
}
const key = m[1] ?? m[2]!;
const valueAt = i + m[0].length;
if (key === keys[0]) {
if (keys.length === 1) {
const lit = /^(["'])((?:\\.|(?!\1).)*)\1/.exec(text.slice(valueAt));
return lit ? lit[2]! : null;
}
return text[valueAt] === '{' ? readObjectPath(text, valueAt, keys.slice(1)) : null;
}
const end = skipValue(valueAt);
if (text[end] !== ',') return null;
i = end + 1;
}
return null;
}
/** The component a lazy route module renders: its default export, else its `Component` export. */
function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null {
const file = resolveImportPath(spec, fromFile, 'typescript', context);
if (!file) return null;
const source = context.readFile(file) ?? '';
const named = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1] ??
(/\bexport\s+(?:const|function|class)\s+Component\b/.test(source) ? 'Component' : null);
if (!named) return null;
const node = context.getNodesInFile(file).find((n) => n.name === named &&
(n.kind === 'function' || n.kind === 'component' || n.kind === 'class' || n.kind === 'constant' || n.kind === 'variable'));
return node?.id ?? null;
}
interface RouteDeclaration {
/** The route's full path as far as the file tells: nesting composed, a constant part shown as `{expr}`. */
path: string;
/** Its path parts, outermost first. */
parts: RoutePart[];
component?: string;
/** A `lazy: () => import('…')` module. */
lazy?: string;
at: number;
}
/** A path-bearing route object or `<Route>` element, with the extent its children sit in. */
interface RouteScope {
part: RoutePart;
at: number;
end: number;
component?: string;
lazy?: string;
/** A `<Route path>` element: a route even with nothing to render (a layout's path). */
jsx?: boolean;
}
/** Structural scanner: strings, comments, JSX and balanced expressions are units. */
function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclaration[] {
const routes: RouteDeclaration[] = [];
const scopes: RouteScope[] = [];
const dataRouter = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(source);
if (!dataRouter && !/<Route\b/.test(source)) return routes;
const literal = (value: string): string | undefined => {
const match = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/.exec(value.trim());
return match ? match[1] ?? match[2] : undefined;
};
// A guard or boundary wrapping the screen (`<ProtectedRoute><AppRoot/></ProtectedRoute>`,
// `<Suspense>`) is not what the route renders; the first element inside it is.
const WRAPPER = /^(?:Suspense|ErrorBoundary|\w*(?:Guard|Provider)|(?:Protected|Private|Auth|Require\w*)\w*)$/;
const componentName = (value: string | undefined, jsx: boolean): string | undefined => {
if (!value) return undefined;
const match = jsx
? /^\s*<\s*([A-Z][\w]*)\s*(?=[\s/>])/.exec(value)
: /^\s*([A-Z][\w]*)\s*$/.exec(value);
return match?.[1];
if (!jsx) return /^\s*([A-Z][\w]*)\s*$/.exec(value)?.[1];
const inner = value.replace(/^\s*\(\s*/, '');
if (!/^<\s*[A-Z]/.test(inner)) return undefined;
const tags = [...inner.matchAll(/<\s*([A-Z][\w]*)\s*(?=[\s/>])/g)].map((m) => m[1]!);
return tags.find((t) => !WRAPPER.test(t)) ?? tags[0];
};
// The few characters before `at`, trailing whitespace skipped: enough for the
// end-anchored checks below without copying the whole prefix per `/` or `<`.
@@ -246,9 +405,13 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
if (dataRouter && ch === '{' && source[i] === close) {
const pathField = fields.get('path');
const path = pathField && literal(pathField.value);
const expr = pathField && path === undefined && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(pathField.value) ? pathField.value : undefined;
const component = componentName(fields.get('element')?.value, true)
?? componentName(fields.get('Component')?.value, false);
if (path !== undefined && component) routes.push({ path: path || '/', component, at: pathField!.at });
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(fields.get('lazy')?.value ?? '')?.[1];
if (pathField && (path !== undefined || expr)) {
scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, end: i, component, lazy });
}
}
return i < source.length ? i + 1 : i;
}
@@ -266,27 +429,49 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
i = unit(i);
attrs.set(attr[0], source.slice(start, i));
}
let scope: RouteScope | undefined;
if (tag[1] === 'Route' && i < source.length) {
const path = literal(attrs.get('path') ?? '');
const expression = (name: string) => attrs.get(name)?.replace(/^\{([\s\S]*)\}$/, '$1');
const component = componentName(expression('component'), false) ?? componentName(expression('element'), true);
if (path) routes.push({ path, component, at });
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(expression('lazy') ?? '')?.[1];
if (path) {
scope = { part: { lit: path }, at, end: source.length, component, lazy, jsx: true };
scopes.push(scope);
}
}
if (source.startsWith('/>', i)) {
if (scope) scope.end = i + 2;
return i + 2;
}
if (source.startsWith('/>', i)) return i + 2;
i++;
while (i < source.length) {
if (source.startsWith('</', i)) {
const end = source.indexOf('>', i + 2);
return end < 0 ? source.length : end + 1;
const after = end < 0 ? source.length : end + 1;
if (scope) scope.end = after;
return after;
}
if (source[i] === '<' && /^<(?:[A-Za-z]|>)/.test(source.slice(i))) i = jsx(i);
else if (source[i] === '{') i = unit(i);
else i++;
}
if (scope) scope.end = i;
return i;
}
let at = 0;
while ((at = trivia(at)) < source.length) at = unit(at);
// A child route's path is relative to the routes around it: compose each
// rendering route's path from the path-bearing scopes that contain it.
for (const scope of scopes) {
if (!scope.component && !scope.lazy && !scope.jsx) continue;
const chain = scopes
.filter((outer) => outer !== scope && outer.at < scope.at && outer.end >= scope.end)
.sort((a, b) => a.at - b.at);
const parts = [...chain.map((c) => c.part), scope.part];
const shown = parts.map((p) => p.lit ?? `{${p.expr}}`);
routes.push({ path: composeRoutePath(shown), parts, component: scope.component, lazy: scope.lazy, at: scope.at });
}
return routes.sort((a, b) => a.at - b.at);
}
+9 -5
View File
@@ -240,7 +240,7 @@ function resolveImportPathUncached(
}
// Handle absolute/aliased imports (like @/ or src/)
const aliased = resolveAliasedImport(importPath, projectRoot, language, context);
const aliased = resolveAliasedImport(importPath, projectRoot, language, context, fromFile);
if (aliased) return aliased;
// C/C++ include directory search: when neither relative nor aliased
@@ -576,7 +576,8 @@ function resolveAliasedImport(
importPath: string,
projectRoot: string,
language: Language,
context: ResolutionContext
context: ResolutionContext,
fromFile?: string
): string | null {
const extensions = EXTENSION_RESOLUTION[language] || [];
const tryWithExt = (basePath: string): string | null => {
@@ -588,9 +589,12 @@ function resolveAliasedImport(
return findSourceForEmittedSpecifier(basePath, language, context);
};
// 1. Project tsconfig/jsconfig paths.
const aliasMap = context.getProjectAliases?.();
if (aliasMap) {
// 1. tsconfig/jsconfig paths: the config nearest the importing file (an
// app of a monorepo keeps its own `@/*`), then the project root's.
const nearest = fromFile ? context.getNearestAliases?.(fromFile) : null;
const rootMap = context.getProjectAliases?.();
for (const aliasMap of nearest && nearest !== rootMap ? [nearest, rootMap] : [rootMap]) {
if (!aliasMap) continue;
const candidates = applyAliases(importPath, aliasMap, projectRoot);
for (const c of candidates) {
const hit = tryWithExt(c);
+27
View File
@@ -307,6 +307,8 @@ export class ReferenceResolver {
// `null` = computed and absent. Treated as immutable for the
// resolver's lifetime; callers re-create the resolver if config changes.
private projectAliases: AliasMap | null | undefined = undefined;
// Per directory: the aliases of the nearest non-root tsconfig declaring `paths`.
private dirAliases = new Map<string, AliasMap | null>();
// go.mod module path. Same lazy/immutable convention as projectAliases.
private goModule: GoModule | null | undefined = undefined;
// Monorepo workspace member packages. Same lazy/immutable convention.
@@ -776,6 +778,31 @@ export class ReferenceResolver {
return this.projectAliases;
},
getNearestAliases: (fromFile: string) => {
let dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
const walked: string[] = [];
let found: AliasMap | null = null;
while (dir && dir !== '.' && dir !== '/') {
const hit = this.dirAliases.get(dir);
if (hit !== undefined) {
found = hit;
break;
}
walked.push(dir);
const abs = path.join(this.projectRoot, dir);
if (['tsconfig.json', 'jsconfig.json'].some((name) => fs.existsSync(path.join(abs, name)))) {
const aliases = loadProjectAliases(abs);
if (aliases) {
found = aliases;
break;
}
}
dir = path.posix.dirname(dir);
}
for (const d of walked) this.dirAliases.set(d, found);
return found;
},
getGoModule: () => {
if (this.goModule === undefined) {
this.goModule = loadGoModule(this.projectRoot);
+5
View File
@@ -181,6 +181,11 @@ export interface ResolutionContext {
* compile without modification; production resolver implements it.
*/
getProjectAliases?(): import('./path-aliases').AliasMap | null;
/**
* The aliases of the tsconfig / jsconfig nearest `fromFile` below the
* project root that declares `paths` — a monorepo app's own `@/*` — or null.
*/
getNearestAliases?(fromFile: string): import('./path-aliases').AliasMap | null;
/**
* Go module info from `go.mod` at the project root. Returns `null`
* when the project has no `go.mod` (non-Go projects, pre-modules