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:
Colby Mchenry
2026-09-30 07:34:14 +00:00
committed by GitHub
parent 67744468d2
commit 72a9346b12
5 changed files with 105 additions and 2 deletions
+1
View File
@@ -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`).
+1 -1
View File
@@ -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` |
+40
View File
@@ -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();
}
});
});
+7 -1
View File
@@ -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;
+56
View File
@@ -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);