expo-router.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. import { describe, it, expect, beforeAll, afterEach } from 'vitest';
  2. import * as fs from 'fs';
  3. import * as path from 'path';
  4. import * as os from 'os';
  5. import { CodeGraph } from '../src';
  6. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  7. import { buildScreens } from '../src/ui-server/api/screens';
  8. import { buildSteps } from '../src/ui-server/api/steps';
  9. import {
  10. expoRouterResolver,
  11. routePathForFile,
  12. defaultExportName,
  13. readHrefArgument,
  14. readHrefViaLocal,
  15. normalizeHrefPath,
  16. } from '../src/resolution/frameworks/expo-router';
  17. import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
  18. import type { Node } from '../src/types';
  19. // =============================================================================
  20. // Route paths from file names
  21. // =============================================================================
  22. describe('expo-router: routePathForFile', () => {
  23. it.each([
  24. ['app/index.tsx', '/'],
  25. ['src/app/index.tsx', '/'],
  26. ['src/app/object-detail.tsx', '/object-detail'],
  27. ['src/app/capture/index.tsx', '/capture'],
  28. ['src/app/capture/review/index.tsx', '/capture/review'],
  29. ['src/app/sheets/need-help.tsx', '/sheets/need-help'],
  30. ['src/app/item/[id].tsx', '/item/[id]'],
  31. ['src/app/docs/[...slug].tsx', '/docs/[...slug]'],
  32. ['src/app/(tabs)/home.tsx', '/home'],
  33. ['src/app/(auth)/(stack)/login.tsx', '/login'],
  34. ['src/app/+not-found.tsx', '/+not-found'],
  35. ['src/app/settings.ios.tsx', '/settings'],
  36. ['src/app/legacy.js', '/legacy'],
  37. ['apps/mobile/src/app/home.tsx', '/home'],
  38. ])('%s → %s', (file, route) => {
  39. expect(routePathForFile(file)).toBe(route);
  40. });
  41. it.each([
  42. 'src/app/_layout.tsx',
  43. 'src/app/(tabs)/_layout.tsx',
  44. 'src/app/+html.tsx',
  45. 'src/app/+native-intent.tsx',
  46. 'src/app/_private-helper.ts',
  47. 'src/app/home.test.tsx',
  48. 'src/app/__tests__/home.tsx',
  49. 'src/app/types.d.ts',
  50. 'src/app/styles.css',
  51. 'src/components/app/thing.tsx'.replace('components/app/', 'components/'), // no app dir
  52. 'src/appearance/theme.tsx',
  53. ])('%s is not a screen', (file) => {
  54. expect(routePathForFile(file)).toBeNull();
  55. });
  56. });
  57. // =============================================================================
  58. // Default export → screen name
  59. // =============================================================================
  60. describe('expo-router: defaultExportName', () => {
  61. it.each([
  62. ['export default function ObjectDetail() {}', 'ObjectDetail'],
  63. ['export default async function Screen() {}', 'Screen'],
  64. ['export default class Legacy extends React.Component {}', 'Legacy'],
  65. ['function Home() {}\nexport default Home', 'Home'],
  66. ['function Home() {}\nexport default Home;', 'Home'],
  67. ['export default memo(Home)', 'Home'],
  68. ['export default React.memo(Home)', 'Home'],
  69. ['export default observer(Home, opts)', 'Home'],
  70. ['export { Home as default }', 'Home'],
  71. ])('%s → %s', (src, name) => {
  72. expect(defaultExportName(src)?.name).toBe(name);
  73. });
  74. it('yields null for an anonymous default export', () => {
  75. expect(defaultExportName('export default () => null')).toBeNull();
  76. expect(defaultExportName('export default function () {}')).toBeNull();
  77. });
  78. });
  79. // =============================================================================
  80. // Reading the href argument
  81. // =============================================================================
  82. describe('expo-router: readHrefArgument', () => {
  83. const read = (src: string, method = 'push', line = 1, column = 0) =>
  84. readHrefArgument(src.split('\n'), line, column, method);
  85. it('reads a plain string', () => {
  86. expect(read("router.push('/capture-queue')")).toEqual({
  87. path: '/capture-queue',
  88. display: '/capture-queue',
  89. });
  90. });
  91. it('drops the query string and hash from the path but keeps them for display', () => {
  92. expect(read('router.push("/sheets/setup-guide?kind=lighting")')).toEqual({
  93. path: '/sheets/setup-guide',
  94. display: '/sheets/setup-guide?kind=lighting',
  95. });
  96. });
  97. it('reads a template literal, keeping the static prefix and marking holes', () => {
  98. const src =
  99. 'router.navigate(\n' +
  100. ' `/object-detail?detectionItem=${encodeParam(JSON.stringify(item))}${folderParam}` as any\n' +
  101. ')';
  102. expect(read(src, 'navigate')).toEqual({
  103. path: '/object-detail',
  104. display: '/object-detail?detectionItem=${…}${…}',
  105. });
  106. });
  107. it('keeps a hole that sits in the path itself', () => {
  108. const r = read('router.push(`/terms-of-service/term/${id}`)');
  109. expect(r?.display).toBe('/terms-of-service/term/${…}');
  110. expect(r?.path.startsWith('/terms-of-service/term/')).toBe(true);
  111. });
  112. it('reads pathname out of an Href object', () => {
  113. const src =
  114. "router.push({\n pathname: '/detection/result/[id]',\n params: { id: result.id },\n})";
  115. expect(read(src)).toEqual({
  116. path: '/detection/result/[id]',
  117. display: '/detection/result/[id]',
  118. });
  119. });
  120. it('reads both arms of a conditional argument', () => {
  121. const src =
  122. 'router.navigate(\n' +
  123. ' (folder.id\n' +
  124. " ? `/sheets/create-detection-item?folderId=${folder.id}`\n" +
  125. " : '/sheets/create-detection-item') as any\n" +
  126. ')';
  127. const r = read(src, 'navigate');
  128. expect(r?.path).toBe('/sheets/create-detection-item');
  129. expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
  130. expect(r?.alternates?.map((a) => a.path)).toEqual(['/sheets/create-detection-item']);
  131. });
  132. it('reads the literal arm when the other is not one — a place the code demonstrably goes', () => {
  133. // Both arms readable is a fork, and `pageForHref` resolves it only when
  134. // they name the same route. One arm readable is not a fork: `/home` is
  135. // somewhere this call goes, and reporting it is not a guess. Dropping it
  136. // cost every react-router app its post-login transition, which is written
  137. // `const redirect = search ? search.split('=')[1] : '/'`.
  138. const r = read("router.push(ready ? '/home' : fallback)");
  139. expect(r?.path).toBe('/home');
  140. expect(r?.alternate).toBeUndefined();
  141. expect(read("router.push(ready ? fallback : '/home')")?.path).toBe('/home');
  142. // Neither arm readable is still nothing.
  143. expect(read('router.push(ready ? a : b)')).toBeNull();
  144. });
  145. it('pairs the arms of a NESTED conditional, and keeps all three', () => {
  146. // Taking the first `:` split this between `keyword` and '/page', reading
  147. // '/page' — a real path, from the wrong arm of the wrong conditional. Paired
  148. // properly it is a paginator that goes to one of three places, and the
  149. // picture draws all three rather than none.
  150. const r = read("router.push(!isAdmin ? keyword ? '/search' : '/page' : '/admin')");
  151. expect([r?.path, ...(r?.alternates ?? []).map((a) => a.path)]).toEqual(['/search', '/page', '/admin']);
  152. });
  153. it('reads only the first argument', () => {
  154. expect(read("router.push('/home', { withAnchor: true })")?.path).toBe('/home');
  155. });
  156. it('starts scanning at the column so an earlier call on the line is skipped', () => {
  157. const src = "list.push(x); router.push('/home')";
  158. expect(read(src, 'push', 1, src.indexOf('router'))?.path).toBe('/home');
  159. });
  160. it('returns null for a non-literal argument', () => {
  161. expect(read('router.push(href)')).toBeNull();
  162. expect(read('router.push(buildHref(item))')).toBeNull();
  163. expect(read('router.push({ pathname, params })')).toBeNull();
  164. expect(read('router.push()')).toBeNull();
  165. });
  166. it('does not run past the call: a later literal is not this call\'s argument', () => {
  167. expect(read("router.back()\nrouter.push('/home')", 'back')).toBeNull();
  168. });
  169. });
  170. describe('expo-router: readHrefViaLocal', () => {
  171. const viaLocal = (src: string, method = 'navigate') => {
  172. const lines = src.split('\n');
  173. const line = lines.findIndex((l) => l.includes(`.${method}(`)) + 1;
  174. return readHrefViaLocal(lines, line, 0, method, 1);
  175. };
  176. it('reads a local const assigned a literal', () => {
  177. expect(viaLocal("function f() {\n const href = '/home'\n router.navigate(href as any)\n}")?.path).toBe('/home');
  178. });
  179. it('reads a multi-line ternary initializer whose arms are literals', () => {
  180. const src =
  181. 'function f(params) {\n' +
  182. ' const href = params.length\n' +
  183. ' ? `/barcode-scan?${params.join("&")}`\n' +
  184. " : '/barcode-scan'\n" +
  185. ' if (options?.replace) {\n' +
  186. ' router.navigate(href as any)\n' +
  187. ' }\n}';
  188. const r = viaLocal(src);
  189. expect(r?.path).toBe('/barcode-scan');
  190. expect(r?.alternates?.map((a) => a.path)).toEqual(['/barcode-scan']);
  191. });
  192. it('reads a typed declaration and an Href object initializer', () => {
  193. expect(viaLocal("const href: Href = '/home'\nrouter.navigate(href)")?.path).toBe('/home');
  194. expect(viaLocal("const href = { pathname: '/item/[id]', params: { id } }\nrouter.navigate(href)")?.path).toBe('/item/[id]');
  195. });
  196. it('refuses a computed initializer, a reassignment, and a non-identifier argument', () => {
  197. expect(viaLocal("const href = build()\nrouter.navigate(href)")).toBeNull();
  198. expect(viaLocal("const href = '/home'\nhref = other\nrouter.navigate(href)")).toBeNull();
  199. expect(viaLocal("router.navigate(a.b)")).toBeNull();
  200. });
  201. it('is not confused by ?. and ?? in an initializer', () => {
  202. expect(viaLocal("const href = options?.href ?? '/home'\nrouter.navigate(href)")).toBeNull();
  203. });
  204. });
  205. // =============================================================================
  206. // Href normalization
  207. // =============================================================================
  208. describe('expo-router: normalizeHrefPath', () => {
  209. it('strips trailing slash and group segments, decodes segments', () => {
  210. expect(normalizeHrefPath('/capture/', 'src/services/nav.ts')).toEqual(['capture']);
  211. expect(normalizeHrefPath('/(tabs)/home', 'src/services/nav.ts')).toEqual(['home']);
  212. expect(normalizeHrefPath('/a%20b', 'src/services/nav.ts')).toEqual(['a b']);
  213. expect(normalizeHrefPath('/', 'src/services/nav.ts')).toEqual([]);
  214. });
  215. it('resolves a relative href against the screen the call is in', () => {
  216. expect(normalizeHrefPath('./review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
  217. expect(normalizeHrefPath('review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
  218. expect(normalizeHrefPath('../home', 'src/app/capture/review.tsx')).toEqual(['home']);
  219. });
  220. it('refuses a relative href from a non-screen file', () => {
  221. expect(normalizeHrefPath('./review', 'src/services/nav.ts')).toBeNull();
  222. });
  223. });
  224. // =============================================================================
  225. // extract(): route node + screen ref
  226. // =============================================================================
  227. describe('expo-router: extract', () => {
  228. it('emits a route node named by path and a calls ref to the default export', () => {
  229. const src = "import React from 'react'\n\nexport default function ObjectDetail() {\n return null\n}\n";
  230. const { nodes, references } = expoRouterResolver.extract!('src/app/object-detail.tsx', src);
  231. expect(nodes).toHaveLength(1);
  232. expect(nodes[0]!.kind).toBe('route');
  233. expect(nodes[0]!.name).toBe('/object-detail');
  234. expect(nodes[0]!.language).toBe('tsx');
  235. expect(references).toHaveLength(1);
  236. expect(references[0]!.fromNodeId).toBe(nodes[0]!.id);
  237. expect(references[0]!.referenceName).toBe('ObjectDetail');
  238. expect(references[0]!.referenceKind).toBe('calls');
  239. expect(references[0]!.line).toBe(3);
  240. });
  241. it('emits nothing for a layout or a non-app file', () => {
  242. expect(expoRouterResolver.extract!('src/app/_layout.tsx', 'export default function L() {}')).toEqual({
  243. nodes: [],
  244. references: [],
  245. });
  246. expect(expoRouterResolver.extract!('src/services/nav.ts', 'export default function x() {}')).toEqual({
  247. nodes: [],
  248. references: [],
  249. });
  250. });
  251. });
  252. // =============================================================================
  253. // resolve(): a navigation call → the route node, as a navigates edge
  254. // =============================================================================
  255. describe('expo-router: resolve', () => {
  256. const route = (filePath: string): Node => expoRouterResolver.extract!(filePath, '').nodes[0]!;
  257. const routes = [
  258. route('src/app/index.tsx'),
  259. route('src/app/object-detail.tsx'),
  260. route('src/app/capture/index.tsx'),
  261. route('src/app/item/[id].tsx'),
  262. route('src/app/docs/[...slug].tsx'),
  263. ];
  264. const files: Record<string, string> = {
  265. 'src/services/nav.ts':
  266. "import { router } from 'expo-router'\n" +
  267. "export function openDetail(item) {\n" +
  268. ' router.navigate(\n' +
  269. ' `/object-detail?detectionItem=${encode(item)}` as any\n' +
  270. ' )\n' +
  271. '}\n' +
  272. "export function openItem(id) { router.push(`/item/${id}`) }\n" +
  273. "export function openDoc() { router.push({ pathname: '/docs/[...slug]', params: { slug: ['a'] } }) }\n" +
  274. "export function openCapture() { router.replace('/capture/') }\n" +
  275. "export function missing() { router.push('/nowhere') }\n" +
  276. "export function computed(h) { router.push(h) }\n" +
  277. "export function notNav(list) { list.push('/capture') }\n" +
  278. "export function fork(x) { router.push(x ? '/capture' : '/object-detail') }\n" +
  279. "export function sameScreen(x) { router.push(x ? '/capture?x=1' : '/capture/') }\n" +
  280. "export function viaWrapper() { safePush('/capture/') }\n",
  281. };
  282. const context = {
  283. getNodesByKind: (kind: Node['kind']) => (kind === 'route' ? routes : []),
  284. getProjectRoot: () => '/proj',
  285. readFile: (p: string) => files[p] ?? null,
  286. getFileLines: (p: string) => files[p]?.split('\n') ?? null,
  287. getAllFiles: () => Object.keys(files),
  288. getNodesInFile: () => [],
  289. getNodesByName: () => [],
  290. getNodesByQualifiedName: () => [],
  291. getNodesByLowerName: () => [],
  292. fileExists: () => true,
  293. getImportMappings: () => [],
  294. } as unknown as ResolutionContext;
  295. const ref = (referenceName: string, line: number, column = 0): UnresolvedRef => ({
  296. fromNodeId: 'function:src',
  297. referenceName,
  298. referenceKind: 'calls',
  299. line,
  300. column,
  301. filePath: 'src/services/nav.ts',
  302. language: 'typescript',
  303. });
  304. it('claims router navigation method names through the name pre-filter', () => {
  305. expect(expoRouterResolver.claimsReference!('router.push')).toBe(true);
  306. expect(expoRouterResolver.claimsReference!('nav.navigate')).toBe(true);
  307. expect(expoRouterResolver.claimsReference!('safePush')).toBe(true);
  308. expect(expoRouterResolver.claimsReference!('guardedNavigate')).toBe(true);
  309. expect(expoRouterResolver.claimsReference!('router.back')).toBe(false);
  310. expect(expoRouterResolver.claimsReference!('fetch')).toBe(false);
  311. expect(expoRouterResolver.claimsReference!('Push')).toBe(false);
  312. });
  313. it('binds a project wrapper named for the verb, remembering the wrapper', () => {
  314. const r = expoRouterResolver.resolve(ref('safePush', 15, 27), context);
  315. expect(r?.targetNodeId).toBe(routes[2]!.id);
  316. expect(r?.metadata).toEqual({ href: '/capture/', navMethod: 'push', via: 'safePush' });
  317. });
  318. it('binds a multi-line template href to its route as a navigates edge with the href', () => {
  319. const r = expoRouterResolver.resolve(ref('router.navigate', 3, 2), context);
  320. expect(r).not.toBeNull();
  321. expect(r!.targetNodeId).toBe(routes[1]!.id);
  322. expect(r!.edgeKind).toBe('navigates');
  323. expect(r!.resolvedBy).toBe('framework');
  324. expect(r!.metadata).toEqual({ href: '/object-detail?detectionItem=${…}', navMethod: 'navigate' });
  325. });
  326. it('matches an interpolated segment against a [param] route', () => {
  327. const r = expoRouterResolver.resolve(ref('router.push', 7, 29), context);
  328. expect(r?.targetNodeId).toBe(routes[3]!.id);
  329. });
  330. it('matches a pathname object against a catch-all route', () => {
  331. const r = expoRouterResolver.resolve(ref('router.push', 8, 28), context);
  332. expect(r?.targetNodeId).toBe(routes[4]!.id);
  333. });
  334. it('normalizes a trailing slash onto an index route', () => {
  335. const r = expoRouterResolver.resolve(ref('router.replace', 9, 32), context);
  336. expect(r?.targetNodeId).toBe(routes[2]!.id);
  337. });
  338. it('returns null for a path with no screen and for a computed href', () => {
  339. expect(expoRouterResolver.resolve(ref('router.push', 10, 28), context)).toBeNull();
  340. expect(expoRouterResolver.resolve(ref('router.push', 11, 30), context)).toBeNull();
  341. });
  342. it('gates on the string naming a real screen, not on the receiver being called router', () => {
  343. // `const nav = useRouter(); nav.push('/x')` must bind, so the receiver is
  344. // not consulted; a non-router `push` of a real screen path binds too.
  345. expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
  346. });
  347. it('binds a conditional whose arms name the same screen, and draws BOTH when they fork', () => {
  348. const same = expoRouterResolver.resolve(ref('router.push', 14, 33), context);
  349. expect(same?.targetNodeId).toBe(routes[2]!.id);
  350. expect(same?.alsoTargets).toBeUndefined();
  351. // A fork reaches both screens, and each becomes an edge of its own.
  352. const forked = expoRouterResolver.resolve(ref('router.push', 13, 27), context);
  353. expect(forked).not.toBeNull();
  354. expect([forked!.targetNodeId, ...(forked!.alsoTargets ?? []).map((t) => t.targetNodeId)]).toHaveLength(2);
  355. });
  356. it('ignores refs that are not calls or not JS/TS', () => {
  357. expect(
  358. expoRouterResolver.resolve({ ...ref('router.push', 9, 32), referenceKind: 'references' }, context)
  359. ).toBeNull();
  360. expect(expoRouterResolver.resolve({ ...ref('router.push', 9, 32), language: 'swift' }, context)).toBeNull();
  361. });
  362. });
  363. // =============================================================================
  364. // End to end: index a small Expo app and walk tap → screen
  365. // =============================================================================
  366. describe('expo-router: end-to-end', () => {
  367. beforeAll(async () => {
  368. await initGrammars();
  369. await loadAllGrammars();
  370. });
  371. let tmpDir: string | undefined;
  372. afterEach(() => {
  373. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  374. tmpDir = undefined;
  375. });
  376. function write(rel: string, content: string) {
  377. const full = path.join(tmpDir!, rel);
  378. fs.mkdirSync(path.dirname(full), { recursive: true });
  379. fs.writeFileSync(full, content);
  380. }
  381. it('connects a component tap to the screen it navigates to', async () => {
  382. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-router-'));
  383. write(
  384. 'package.json',
  385. JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } })
  386. );
  387. write('src/app/_layout.tsx', "export default function Layout() { return null }\n");
  388. write(
  389. 'src/app/index.tsx',
  390. "import { ItemCard } from '../components/item-card'\n" +
  391. 'export default function Home() {\n' +
  392. " return <ItemCard item={{ id: '1' }} collected />\n" +
  393. '}\n'
  394. );
  395. write(
  396. 'src/app/object-detail.tsx',
  397. "export default function ObjectDetail() {\n return null\n}\n"
  398. );
  399. write('src/app/item/[id].tsx', "export default function Item() { return null }\n");
  400. write(
  401. 'src/services/nav.ts',
  402. "import { router } from 'expo-router'\n" +
  403. 'export function openObjectDetail(item: { id: string }) {\n' +
  404. ' router.navigate(\n' +
  405. ' `/object-detail?detectionItem=${JSON.stringify(item)}` as any\n' +
  406. ' )\n' +
  407. '}\n' +
  408. 'export function openItem(id: string) {\n' +
  409. " router.push({ pathname: '/item/[id]', params: { id } })\n" +
  410. '}\n'
  411. );
  412. write(
  413. 'src/app/welcome.tsx',
  414. "export default function Welcome() { return null }\n"
  415. );
  416. write(
  417. 'src/services/post-login.ts',
  418. // The literal-union return type is the trap: its routes are string
  419. // literals too, BEFORE the ternary — the scan must skip the signature
  420. // or the annotation's guardless positions win.
  421. 'export const resolvePostLoginRoute = async (): Promise<\n' +
  422. " '/welcome/' | '/'\n" +
  423. '> => {\n' +
  424. " return (await seen()) ? '/' : '/welcome/'\n" +
  425. '}\n' +
  426. 'async function seen() { return true }\n' +
  427. "export function apiPath() { return '/api/users' }\n"
  428. );
  429. write(
  430. 'src/services/login.ts',
  431. "import { router } from 'expo-router'\n" +
  432. "import { resolvePostLoginRoute, apiPath } from './post-login'\n" +
  433. 'export async function finishLogin() {\n' +
  434. ' router.replace(await resolvePostLoginRoute())\n' +
  435. '}\n' +
  436. 'export function fetchUsers() { return fetch(apiPath()) }\n'
  437. );
  438. write(
  439. 'src/components/item-card.tsx',
  440. "import { openObjectDetail } from '../services/nav'\n" +
  441. 'export function ItemCard(props: { item: { id: string }; collected: boolean }) {\n' +
  442. ' const handlePress = () => {\n' +
  443. ' if (props.collected) openObjectDetail(props.item)\n' +
  444. ' }\n' +
  445. ' return handlePress\n' +
  446. '}\n'
  447. );
  448. const cg = CodeGraph.initSync(tmpDir);
  449. await cg.indexAll();
  450. const routes = cg.getNodesByKind('route');
  451. expect(routes.map((r) => r.name).sort()).toEqual(['/', '/item/[id]', '/object-detail', '/welcome']);
  452. const detailRoute = routes.find((r) => r.name === '/object-detail')!;
  453. // route → its screen component
  454. const screen = cg.getNodesByName('ObjectDetail').find((n) => n.kind !== 'route')!;
  455. expect(screen).toBeDefined();
  456. const toScreen = cg.getOutgoingEdges(detailRoute.id).find((e) => e.target === screen.id);
  457. expect(toScreen?.kind).toBe('calls');
  458. // navigation call → route, as a navigates edge that remembers the href
  459. const opener = cg.getNodesByName('openObjectDetail')[0]!;
  460. const nav = cg.getOutgoingEdges(opener.id).find((e) => e.target === detailRoute.id);
  461. expect(nav?.kind).toBe('navigates');
  462. expect(nav?.metadata?.href).toBe('/object-detail?detectionItem=${…}');
  463. expect(nav?.metadata?.navMethod).toBe('navigate');
  464. expect(nav?.metadata?.refKind).toBe('calls');
  465. // the pathname-object form binds the dynamic route
  466. const itemRoute = routes.find((r) => r.name === '/item/[id]')!;
  467. const openItem = cg.getNodesByName('openItem')[0]!;
  468. expect(cg.getOutgoingEdges(openItem.id).some((e) => e.target === itemRoute.id && e.kind === 'navigates')).toBe(true);
  469. // the route's callers are the navigators — what "who opens this screen" asks
  470. const callers = cg.getCallers(detailRoute.id);
  471. expect(callers.map((c) => c.node.name)).toContain('openObjectDetail');
  472. // `router.replace(await resolvePostLoginRoute())`: the helper's return
  473. // literals become heuristic navigates edges FROM THE HELPER, one per screen,
  474. // remembering the push site; the plain `calls` edge from the pusher closes
  475. // the chain. A helper nothing navigates with (`apiPath`) is never read.
  476. const helper = cg.getNodesByName('resolvePostLoginRoute')[0]!;
  477. const fromHelper = cg.getOutgoingEdges(helper.id).filter((e) => e.kind === 'navigates');
  478. expect(fromHelper.map((e) => routes.find((r) => r.id === e.target)?.name).sort()).toEqual(['/', '/welcome']);
  479. expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
  480. expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
  481. expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
  482. // Each return literal carries its own POSITION: the two arms of
  483. // `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
  484. // column lets the guard reader say which arm an edge is — without it both
  485. // navigations read as `always`.
  486. const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
  487. const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
  488. expect(rootEdge.line).toBe(welcomeEdge.line);
  489. expect(typeof rootEdge.column).toBe('number');
  490. expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
  491. const finishLogin = cg.getNodesByName('finishLogin')[0]!;
  492. expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
  493. const apiPath = cg.getNodesByName('apiPath')[0]!;
  494. expect(cg.getOutgoingEdges(apiPath.id).some((e) => e.kind === 'navigates')).toBe(false);
  495. // The Screens payload: the tap on ItemCard is attributed back to the Home
  496. // screen through the JSX-render hop, with the chain and its condition.
  497. const screens = await buildScreens(cg, tmpDir);
  498. expect(screens.routed).toBe(true);
  499. const home = screens.screens.find((s) => s.path === '/')!;
  500. expect(screens.entry).toBe(home.id);
  501. const detail = screens.screens.find((s) => s.path === '/object-detail')!;
  502. const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
  503. expect(tap).toBeDefined();
  504. expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
  505. expect(tap.when).toBe('props.collected');
  506. expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
  507. // Navigation nothing on a screen reaches is an origin, not dropped: the
  508. // post-login helper, and `openItem`, which the fixture never calls.
  509. const fromOrigins = screens.links.filter((l) => l.fromOrigin);
  510. expect(fromOrigins.map((l) => screens.screens.find((s) => s.id === l.to)!.path).sort()).toEqual(['/', '/item/[id]', '/welcome']);
  511. expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
  512. expect(screens.dropped).toBe(0);
  513. // The steps walk reads each arm's own condition off the literal's column:
  514. // where the app goes after login is a fork, not two `always`es.
  515. const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
  516. const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
  517. const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
  518. const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
  519. expect(toRoot.when).toMatch(/await seen\(\)/);
  520. expect(toRoot.when).not.toMatch(/!/);
  521. expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
  522. // …and each site names the DECISION its condition belongs to, so the two
  523. // arms can be drawn as one choice rather than as two lines that happen to
  524. // read as each other's negation. Same branch, opposite arms, one `on`.
  525. const rootArm = toRoot.sites[0]!.decision!;
  526. const welcomeArm = toWelcome.sites[0]!.decision!;
  527. expect(rootArm.branch).toBe(welcomeArm.branch);
  528. expect(rootArm.branch).not.toBe('');
  529. expect(rootArm.form).toBe('ternary');
  530. expect(rootArm.on).toBe(welcomeArm.on);
  531. expect(rootArm.on).toMatch(/await seen\(\)/);
  532. expect(rootArm.on).not.toMatch(/^!/);
  533. expect(rootArm.not).toBeUndefined();
  534. expect(welcomeArm.not).toBe(true);
  535. expect(rootArm.arm).not.toBe(welcomeArm.arm);
  536. cg.close();
  537. });
  538. });
  539. // =============================================================================
  540. // The backward walk must not leave the app's own execution context
  541. // =============================================================================
  542. /**
  543. * A navigation written inside a component the graph can only reach BACKWARDS
  544. * through the native bridge belongs to the screen whose file it is written in
  545. * — not to whichever screen happened to start the round trip.
  546. *
  547. * The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
  548. * renders `memo(CaptureComponent)`; the `router.push` lives in an inline
  549. * listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
  550. * except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
  551. * edge from the memo to the function — so before the guard the walk escaped
  552. * through `rn-event-channel`, came back down into `ReviewScreen` (which had
  553. * called the native module), and filed four of `/capture`'s navigations under
  554. * `/capture/review`, whose only remaining feed was itself. It also carried the
  555. * Swift guards home as conditions on a JavaScript navigation.
  556. */
  557. describe('expo-router screens: attribution stops at the native bridge', () => {
  558. let tmpDir: string | undefined;
  559. afterEach(() => {
  560. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  561. tmpDir = undefined;
  562. });
  563. function write(rel: string, content: string) {
  564. const full = path.join(tmpDir!, rel);
  565. fs.mkdirSync(path.dirname(full), { recursive: true });
  566. fs.writeFileSync(full, content);
  567. }
  568. it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
  569. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
  570. write(
  571. 'package.json',
  572. JSON.stringify({
  573. name: 'app',
  574. dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
  575. })
  576. );
  577. write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
  578. write('src/app/index.tsx', 'export default function Home() { return null }\n');
  579. // The Swift side: a method the JS calls, which ends in the event emit.
  580. write(
  581. 'ios/CaptureView.swift',
  582. `import Foundation
  583. @objc(CaptureView)
  584. class CaptureView: NSObject {
  585. @objc func startRetake() {
  586. CaptureEvents.shared.emitCaptureComplete()
  587. }
  588. }
  589. `
  590. );
  591. // The ObjC bridging shim, without which the JS side never reaches Swift.
  592. write(
  593. 'ios/CaptureView.m',
  594. `#import <React/RCTBridgeModule.h>
  595. @interface RCT_EXTERN_MODULE(CaptureView, NSObject)
  596. RCT_EXTERN_METHOD(startRetake)
  597. @end
  598. `
  599. );
  600. write(
  601. 'ios/CaptureEvents.swift',
  602. `import Foundation
  603. class CaptureEvents: RCTEventEmitter {
  604. func emitCaptureComplete() {
  605. guard Thread.isMainThread else { return }
  606. sendEvent(withName: "onCaptureComplete", body: nil)
  607. }
  608. }
  609. `
  610. );
  611. // /capture — the push is written HERE, in an inline listener inside a
  612. // sibling of the route's own default export.
  613. write(
  614. 'src/app/capture/index.tsx',
  615. `import { memo, useEffect } from 'react'
  616. import { router } from 'expo-router'
  617. const MemoizedCaptureComponent = memo(CaptureComponent)
  618. export default function ARCapturePage() {
  619. return <MemoizedCaptureComponent />
  620. }
  621. function CaptureComponent() {
  622. useEffect(() => {
  623. const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
  624. if (!isRetakeBatchActive) {
  625. router.push('/capture/review')
  626. }
  627. })
  628. return () => sub.remove()
  629. }, [])
  630. return null
  631. }
  632. `
  633. );
  634. // /capture/review — calls into the native module, which is what makes the
  635. // Swift emitter backwards-reachable from this screen.
  636. write(
  637. 'src/app/capture/review/index.tsx',
  638. `import { NativeModules } from 'react-native'
  639. const { CaptureView } = NativeModules
  640. export default function ReviewScreen() {
  641. function handleRetake() {
  642. CaptureView.startRetake()
  643. }
  644. return handleRetake
  645. }
  646. `
  647. );
  648. const cg = CodeGraph.initSync(tmpDir);
  649. await cg.indexAll();
  650. // The escape route the walk used to take really is in the graph.
  651. const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
  652. const bridged = cg
  653. .getIncomingEdgesTo([capture.id], ['calls'])
  654. .filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
  655. expect(bridged.length).toBeGreaterThan(0);
  656. // …and it really is a route back OUT to the other screen: without the
  657. // guard the walk runs handleRetake > startRetake > emitCaptureComplete >
  658. // CaptureComponent and lands the push on /capture/review.
  659. const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
  660. expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
  661. 'handleRetake'
  662. );
  663. const screens = await buildScreens(cg, tmpDir);
  664. const from = (path: string) => screens.screens.find((s) => s.path === path)!;
  665. const review = from('/capture/review');
  666. const links = screens.links.filter((l) => l.to === review.id);
  667. // One transition into /capture/review, and it comes from /capture.
  668. expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
  669. // Written right there: no chain, and no Swift guard smuggled in.
  670. expect(links[0]!.via).toEqual([]);
  671. expect(links[0]!.when).toBe('!isRetakeBatchActive');
  672. expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
  673. // …and /capture/review is not left feeding only itself.
  674. expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
  675. cg.close();
  676. });
  677. });