From 72a9346b122be1f95a342c524fb6546105e57191 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 07:34:14 +0000 Subject: [PATCH] fix(expo-router): +api files are endpoints; the nearest router manifest decides (#2135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 --- CHANGELOG.md | 1 + README.md | 2 +- __tests__/monorepo-app-frameworks.test.ts | 40 ++++++++++++++++ src/extraction/index.ts | 8 +++- src/resolution/frameworks/expo-router.ts | 56 +++++++++++++++++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c959e44..25ff68733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). diff --git a/README.md b/README.md index 284027063..6e91a52f6 100644 --- a/README.md +++ b/README.md @@ -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, `` and internal `` | | **React Router** | `` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `` / `` / `` / v5's `` / react-router-bootstrap's ``, 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 `` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `` / `` — where `to` is the route PATTERN and the values ride beside it in `params` | diff --git a/__tests__/monorepo-app-frameworks.test.ts b/__tests__/monorepo-app-frameworks.test.ts index f7efbf8ca..c5a4664fc 100644 --- a/__tests__/monorepo-app-frameworks.test.ts +++ b/__tests__/monorepo-app-frameworks.test.ts @@ -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(); + } + }); }); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 706bc0bd1..bb5b9c0fa 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -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; diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts index 5d82b94e2..ef6f44d81 100644 --- a/src/resolution/frameworks/expo-router.ts +++ b/src/resolution/frameworks/expo-router.ts @@ -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(); + 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);