mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 01:07:18 +08:00
fix(expo-router): +api files are endpoints; the nearest router manifest decides (#2135)
- `app/blog/og-image/[post]+api.ts` was a screen named `/blog/og-image/[post]+api` (evanbacon.dev). An Expo API route is now one endpoint per exported HTTP method (`GET /blog/og-image/:post`), bound to its handler, the way Next.js `route.ts` files are. - The per-app framework gate (#2129) took ANY enclosing manifest's declaration: react-native-true-sheet's root declares expo-router for its example app, so its `docs/` Next.js app got 16 Expo screens (`/layout`, `/page`, `/api/search/route`, …). Walking up from a file, the first manifest that names any gated framework now decides. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Expo Router API routes (`app/hello+api.ts`) are now endpoints, one per exported method, like `GET /hello`, bound to the function that handles them. They used to show up as a screen named `/hello+api`. In a repository whose root declares Expo Router for an example app, a Next.js app in its own folder, like react-native-true-sheet's `docs/`, no longer gets Expo screens made from its files.
|
||||
- Calls into an Expo module now reach the module's native functions even when the JavaScript names the module something else, like expo-camera's `CameraManager` for `requireNativeModule('ExpoCamera')`. Both the iOS and the Android implementations are linked. expo-camera's calls used to link back to the same-named method making the call, so a method looked like it called itself.
|
||||
- A JavaScript or TypeScript call on something the file imports, like `fireEvent.click(…)` or `util.omit(…)`, no longer links to a same-named method declared in the calling file itself.
|
||||
- TypeScript's `import type { X }` no longer reads as an import of something named `type`, and `import { a, type B }` binds `B`, not `type B`. So a type-only import now links to the module it names, where it used to link to whatever project symbol had that name. A type imported from a package, like `RsbuildConfig` from `@rsbuild/core`, no longer links to a project symbol, and JavaScript and TypeScript names no longer match a symbol whose name differs only in case (`Mock` is not `mock`).
|
||||
|
||||
@@ -340,7 +340,7 @@ These frameworks additionally emit **`navigates`** edges: the function that send
|
||||
|
||||
| Router | Routes from | Navigation from |
|
||||
|---|---|---|
|
||||
| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href |
|
||||
| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component; `+api` files are endpoints (`GET /hello`) bound to their handlers | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href |
|
||||
| **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `<Link href>` and internal `<a href>` |
|
||||
| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / v5's `<Redirect to>` / react-router-bootstrap's `<LinkContainer to>`, and a `styled(Link)` wrapper |
|
||||
| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `<Outlet/>` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `<Link to>` / `<Navigate to>` — where `to` is the route PATTERN and the values ride beside it in `params` |
|
||||
|
||||
@@ -85,4 +85,44 @@ describe('file-based routers in a monorepo', () => {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the nearest manifest that names a router decides: a Next docs app under an Expo root', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ name: 'true-sheet', devDependencies: { expo: '*', 'expo-router': '*', react: '*' } }),
|
||||
'docs/package.json': JSON.stringify({ name: 'docs', dependencies: { next: '*', react: '*' } }),
|
||||
'docs/app/layout.tsx': `export default function RootLayout({ children }: { children: unknown }) { return children; }
|
||||
`,
|
||||
'docs/app/(docs)/[...slug]/page.tsx': `export default function DocPage() { return null; }
|
||||
`,
|
||||
'example/app/_layout.tsx': `export default function Layout() { return null; }
|
||||
`,
|
||||
'example/app/sheet.tsx': `export default function Sheet() { return null; }
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(routesIn(cg, 'docs/')).toEqual(['/:slug*']);
|
||||
expect(routesIn(cg, 'example/')).toEqual(['/sheet']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('an Expo API route is an endpoint per exported method, not a screen', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ name: 'site', dependencies: { expo: '*', 'expo-router': '*' } }),
|
||||
'app/index.tsx': `export default function Home() { return null; }
|
||||
`,
|
||||
'app/blog/og-image/[post]+api.ts': `export async function GET(request: Request) { return new Response('png'); }
|
||||
export const POST = async () => new Response('ok');
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(routesIn(cg, 'app/')).toEqual(['/', 'GET /blog/og-image/:post', 'POST /blog/og-image/:post']);
|
||||
const get = cg.getNodesByKind('route').find((n) => n.name.startsWith('GET '))!;
|
||||
const handler = cg.getOutgoingEdges(get.id).map((e) => cg.getNode(e.target)!).find((n) => n.name === 'GET');
|
||||
expect(handler?.kind).toBe('function');
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1948,13 +1948,19 @@ export class ExtractionOrchestrator {
|
||||
const key = `${dir}|${name}`;
|
||||
const memo = this.appFrameworkMemo.get(key);
|
||||
if (memo !== undefined) return memo;
|
||||
// The nearest manifest that names ANY gated framework decides: true-sheet's
|
||||
// root package.json declares expo-router for its example app, and its
|
||||
// `docs/` Next.js app — whose own package.json declares `next` — is not
|
||||
// an Expo app for it.
|
||||
let applies = false;
|
||||
for (let d: string | null = dir; d !== null; d = d === '' ? null : d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') {
|
||||
const declared = this.dependenciesDeclaredIn(d);
|
||||
if (declared && deps.some((dep) => declared.has(dep))) {
|
||||
if (!declared) continue;
|
||||
if (deps.some((dep) => declared.has(dep))) {
|
||||
applies = true;
|
||||
break;
|
||||
}
|
||||
if ([...this.gatedFrameworks].some(([other, otherDeps]) => other !== name && otherDeps.some((dep) => declared.has(dep)))) break;
|
||||
}
|
||||
this.appFrameworkMemo.set(key, applies);
|
||||
return applies;
|
||||
|
||||
@@ -70,6 +70,8 @@ export function routePathForFile(filePath: string): string | null {
|
||||
const segs = bare.split('/');
|
||||
if (segs.includes('__tests__') || segs.includes('__mocks__')) return null;
|
||||
const base = segs[segs.length - 1]!;
|
||||
// `hello+api.ts` is an endpoint (`apiRoutePathForFile`), not a screen.
|
||||
if (base.endsWith('+api')) return null;
|
||||
// `_layout` (and any other `_`-prefixed file) is not navigable. `+not-found`
|
||||
// is a real screen; the other `+` files (`+html`, `+native-intent`) are not.
|
||||
if (base.startsWith('_')) return null;
|
||||
@@ -79,6 +81,26 @@ export function routePathForFile(filePath: string): string | null {
|
||||
return '/' + kept.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* An API route's path — `app/blog/og-image/[post]+api.ts` is
|
||||
* `/blog/og-image/:post`, written the way every server route is so a
|
||||
* client's `fetch('/blog/og-image/…')` can find it. Null for any other file.
|
||||
*/
|
||||
export function apiRoutePathForFile(filePath: string): string | null {
|
||||
const dir = APP_DIR.exec(filePath);
|
||||
if (!dir) return null;
|
||||
const rel = filePath.slice(dir.index + dir[0].length);
|
||||
const ext = /\.(tsx|ts|jsx|js|mjs|cjs)$/.exec(rel);
|
||||
if (!ext) return null;
|
||||
const bare = rel.slice(0, ext.index);
|
||||
if (!bare.endsWith('+api')) return null;
|
||||
const segs = bare.slice(0, -'+api'.length).split('/').filter((seg) => seg.length > 0 && !(seg.startsWith('(') && seg.endsWith(')')));
|
||||
if (segs[segs.length - 1] === 'index') segs.pop();
|
||||
return '/' + segs.map((seg) => seg.replace(/^\[\.\.\.(.+)\]$/, ':$1*').replace(/^\[(.+)\]$/, ':$1')).join('/');
|
||||
}
|
||||
|
||||
const API_METHODS = 'GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS';
|
||||
|
||||
function languageForFile(filePath: string): Language {
|
||||
const ext = ROUTE_EXT.exec(filePath)?.[1];
|
||||
switch (ext) {
|
||||
@@ -728,6 +750,40 @@ export const expoRouterResolver: FrameworkResolver = {
|
||||
},
|
||||
|
||||
extract(filePath: string, content: string) {
|
||||
const apiPath = apiRoutePathForFile(filePath);
|
||||
if (apiPath !== null) {
|
||||
// `export async function GET(request) {…}` / `export const POST = …` — one endpoint per method.
|
||||
const language = languageForFile(filePath);
|
||||
const stripped = stripCommentsForRegex(content, 'typescript');
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
const decl = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+(${API_METHODS})\\b|\\bexport\\s+(?:const|let)\\s+(${API_METHODS})\\s*=`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = decl.exec(stripped)) !== null) {
|
||||
const method = (m[1] ?? m[2])!;
|
||||
if (seen.has(method)) continue;
|
||||
seen.add(method);
|
||||
const line = stripped.slice(0, m.index).split('\n').length;
|
||||
const node: Node = {
|
||||
id: `route:${filePath}:${line}:${method}:${apiPath}`,
|
||||
kind: 'route',
|
||||
name: `${method} ${apiPath}`,
|
||||
qualifiedName: `${filePath}::${method}:${apiPath}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: m[0].length,
|
||||
language,
|
||||
isExported: true,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
nodes.push(node);
|
||||
references.push({ fromNodeId: node.id, referenceName: method, referenceKind: 'references', line, column: 0, filePath, language, candidates: [method] });
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
const routePath = routePathForFile(filePath);
|
||||
if (routePath === null) return { nodes: [], references: [] };
|
||||
const language = languageForFile(filePath);
|
||||
|
||||
Reference in New Issue
Block a user