tanstack-router.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /**
  2. * TanStack Router as a Screens app (`src/resolution/frameworks/tanstack-router.ts`,
  3. * `src/resolution/tanstack-router-synthesizer.ts`): routes declared file-based
  4. * (`createFileRoute('/posts/$postId')`) and code-based (`createRoute({ path,
  5. * getParentRoute })`), and the navigation between them — where the destination
  6. * is the route PATTERN rather than a filled URL, and rides under a `to` key.
  7. *
  8. * The fixture is the TanStack kitchen-sink and basic examples' shape. Mirrors
  9. * `react-router.test.ts`.
  10. */
  11. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { CodeGraph } from '../src';
  16. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  17. import { buildScreens } from '../src/ui-server/api/screens';
  18. import {
  19. parseTanstackRoutes,
  20. tanstackPath,
  21. tanstackNavVerb,
  22. tanstackDestination,
  23. } from '../src/resolution/frameworks/tanstack-router';
  24. import type { Node } from '../src/types';
  25. // =============================================================================
  26. // Paths
  27. // =============================================================================
  28. describe('tanstack: tanstackPath', () => {
  29. it.each([
  30. ['/', '/'],
  31. ['/login', '/login'],
  32. ['/posts/$postId', '/posts/:postId'],
  33. // A pathless layout is not in the URL; nor is a route group.
  34. ['/_auth/profile', '/profile'],
  35. ['/_pathlessLayout/route-a', '/route-a'],
  36. ['/(this-folder-is-not-in-the-url)/route-group', '/route-group'],
  37. // An index route's trailing slash is the address of its parent.
  38. ['/dashboard/', '/dashboard'],
  39. // A trailing `_` un-nests without changing the segment.
  40. ['/posts_/$postId/edit', '/posts/:postId/edit'],
  41. ['/files/$', '/files/:splat*'],
  42. ])('%s → %s', (raw, normalized) => {
  43. expect(tanstackPath(raw)).toBe(normalized);
  44. });
  45. it('a path that names no address is nothing', () => {
  46. expect(tanstackPath('posts')).toBeNull();
  47. });
  48. });
  49. // =============================================================================
  50. // Reading the routes
  51. // =============================================================================
  52. describe('tanstack: parseTanstackRoutes — file-based', () => {
  53. it('takes the path from the literal and the component from the options', () => {
  54. const src =
  55. "import { createFileRoute } from '@tanstack/react-router'\n" +
  56. "export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({\n" +
  57. ' params: { parse: (p) => ({ invoiceId: Number(p.invoiceId) }) },\n' +
  58. ' component: InvoiceComponent,\n' +
  59. '})\n';
  60. expect(parseTanstackRoutes(src)).toEqual([
  61. { path: '/dashboard/invoices/:invoiceId', component: 'InvoiceComponent', index: false, fileBased: true, line: 2 },
  62. ]);
  63. });
  64. it('finds a component written on a chained .update()', () => {
  65. const src =
  66. "export const Route = createFileRoute('/login')({\n" +
  67. ' validateSearch: z.object({ redirect: z.string().optional() }),\n' +
  68. '}).update({\n' +
  69. ' component: LoginComponent,\n' +
  70. '})\n';
  71. expect(parseTanstackRoutes(src)[0]).toMatchObject({ path: '/login', component: 'LoginComponent' });
  72. });
  73. it('marks an index route, and drops a pathless layout that is no address of its own', () => {
  74. expect(parseTanstackRoutes("createFileRoute('/dashboard/')({ component: X })")[0]).toMatchObject({
  75. path: '/dashboard',
  76. index: true,
  77. });
  78. expect(parseTanstackRoutes("createFileRoute('/_auth')({ component: X })")).toEqual([]);
  79. // …but the index INSIDE a pathless layout is the page at that layout's
  80. // address — `_layout/index.tsx` is a project's home page.
  81. expect(parseTanstackRoutes("createFileRoute('/_layout/')({ component: Home })")[0]).toMatchObject({
  82. path: '/',
  83. index: true,
  84. });
  85. });
  86. });
  87. describe('tanstack: parseTanstackRoutes — code-based', () => {
  88. const src =
  89. "import { createRootRoute, createRoute } from '@tanstack/react-router'\n" +
  90. 'const rootRoute = createRootRoute({ component: RootComponent })\n' +
  91. 'const indexRoute = createRoute({\n' +
  92. ' getParentRoute: () => rootRoute,\n' +
  93. " path: '/',\n" +
  94. ' component: IndexComponent,\n' +
  95. '})\n' +
  96. 'const postsLayoutRoute = createRoute({\n' +
  97. ' getParentRoute: () => rootRoute,\n' +
  98. " path: 'posts',\n" +
  99. ' component: PostsLayoutComponent,\n' +
  100. '})\n' +
  101. 'const postsIndexRoute = createRoute({\n' +
  102. ' getParentRoute: () => postsLayoutRoute,\n' +
  103. " path: '/',\n" +
  104. ' component: PostsIndexComponent,\n' +
  105. '})\n' +
  106. 'const postRoute = createRoute({\n' +
  107. ' getParentRoute: () => postsLayoutRoute,\n' +
  108. " path: '$postId',\n" +
  109. ' component: PostComponent,\n' +
  110. '})\n' +
  111. 'const pathlessRoute = createRoute({\n' +
  112. ' getParentRoute: () => rootRoute,\n' +
  113. " id: 'pathless',\n" +
  114. ' component: PathlessComponent,\n' +
  115. '})\n' +
  116. 'const routeARoute = createRoute({\n' +
  117. ' getParentRoute: () => pathlessRoute,\n' +
  118. " path: '/route-a',\n" +
  119. ' component: RouteAComponent,\n' +
  120. '})\n';
  121. it('composes a path through getParentRoute, and a pathless layout adds nothing to it', () => {
  122. expect(parseTanstackRoutes(src).map((r) => [r.path, r.component])).toEqual([
  123. ['/', 'IndexComponent'],
  124. ['/posts', 'PostsIndexComponent'],
  125. ['/posts/:postId', 'PostComponent'],
  126. ['/route-a', 'RouteAComponent'],
  127. ]);
  128. });
  129. it('a layout with children is not itself a page at that address', () => {
  130. // `postsLayoutRoute` sits at `/posts` and wraps the index that renders there.
  131. const posts = parseTanstackRoutes(src).filter((r) => r.path === '/posts');
  132. expect(posts).toHaveLength(1);
  133. expect(posts[0]!.component).toBe('PostsIndexComponent');
  134. });
  135. });
  136. // =============================================================================
  137. // Destinations
  138. // =============================================================================
  139. describe('tanstack: destinations', () => {
  140. it.each([
  141. ['navigate', 'navigate'],
  142. ['redirect', 'redirect'],
  143. ['router.navigate', 'navigate'],
  144. ])('%s is a navigation', (name, verb) => {
  145. expect(tanstackNavVerb(name)).toBe(verb);
  146. });
  147. it.each(['push', 'replace', 'paths.push', 'goto'])('%s is not', (name) => {
  148. expect(tanstackNavVerb(name)).toBeNull();
  149. });
  150. it('reads the `to` key, and normalises the pattern the way a route name is', () => {
  151. expect(tanstackDestination("{ to: '/posts/$postId' }")?.path).toBe('/posts/:postId');
  152. expect(tanstackDestination("{ to: '/login', search: { redirect } }")?.path).toBe('/login');
  153. expect(tanstackDestination("'/posts/$postId'")?.path).toBe('/posts/:postId');
  154. });
  155. it('a navigation with no destination changes the search on the page it is on', () => {
  156. expect(tanstackDestination('{ search: (old) => ({ ...old, page: 2 }) }')).toBeNull();
  157. });
  158. });
  159. // =============================================================================
  160. // The whole picture, indexed
  161. // =============================================================================
  162. describe('tanstack: a routed app end to end', () => {
  163. let tmpDir: string;
  164. let cg: CodeGraph;
  165. function write(rel: string, content: string): void {
  166. const full = path.join(tmpDir, rel);
  167. fs.mkdirSync(path.dirname(full), { recursive: true });
  168. fs.writeFileSync(full, content);
  169. }
  170. beforeAll(async () => {
  171. await initGrammars();
  172. await loadAllGrammars();
  173. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tanstack-'));
  174. write('package.json', JSON.stringify({ name: 'app', dependencies: { react: '19', '@tanstack/react-router': '1' } }));
  175. write(
  176. 'src/routes/index.tsx',
  177. "import { createFileRoute, Link } from '@tanstack/react-router'\n" +
  178. "export const Route = createFileRoute('/')({ component: IndexComponent })\n" +
  179. 'function IndexComponent() {\n' +
  180. ' return (\n' +
  181. ' <div>\n' +
  182. ' <Link\n' +
  183. ' to="/posts/$postId"\n' +
  184. ' params={{ postId: 3 }}\n' +
  185. ' >\n' +
  186. ' A post\n' +
  187. ' </Link>\n' +
  188. ' <Link to="/login">Sign in</Link>\n' +
  189. ' </div>\n' +
  190. ' )\n' +
  191. '}\n'
  192. );
  193. write(
  194. 'src/routes/posts.route.tsx',
  195. "import { createFileRoute, Outlet } from '@tanstack/react-router'\n" +
  196. "export const Route = createFileRoute('/posts')({ component: PostsLayout })\n" +
  197. 'function PostsLayout() {\n return <Outlet />\n}\n'
  198. );
  199. write(
  200. 'src/routes/posts.index.tsx',
  201. "import { createFileRoute } from '@tanstack/react-router'\n" +
  202. "export const Route = createFileRoute('/posts/')({ component: PostsIndexComponent })\n" +
  203. 'function PostsIndexComponent() {\n return <div>Posts</div>\n}\n'
  204. );
  205. write(
  206. 'src/routes/posts.$postId.tsx',
  207. "import { createFileRoute } from '@tanstack/react-router'\n" +
  208. "export const Route = createFileRoute('/posts/$postId')({ component: PostComponent })\n" +
  209. 'function PostComponent() {\n return <div>Post</div>\n}\n'
  210. );
  211. write(
  212. 'src/routes/login.tsx',
  213. "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" +
  214. "export const Route = createFileRoute('/login')({ component: LoginComponent })\n" +
  215. 'function LoginComponent() {\n' +
  216. ' const navigate = useNavigate()\n' +
  217. ' async function submit(creds) {\n' +
  218. ' const ok = await signIn(creds)\n' +
  219. " if (ok) navigate({ to: '/dashboard' })\n" +
  220. ' }\n' +
  221. ' return <form onSubmit={submit} />\n' +
  222. '}\n'
  223. );
  224. write(
  225. 'src/routes/_auth.tsx',
  226. "import { createFileRoute, redirect } from '@tanstack/react-router'\n" +
  227. "export const Route = createFileRoute('/_auth')({\n" +
  228. ' beforeLoad: ({ context }) => {\n' +
  229. " if (context.auth.status === 'loggedOut') {\n" +
  230. " throw redirect({ to: '/login' })\n" +
  231. ' }\n' +
  232. ' },\n' +
  233. '})\n'
  234. );
  235. write(
  236. 'src/routes/_auth.dashboard.tsx',
  237. "import { createFileRoute, Link } from '@tanstack/react-router'\n" +
  238. "export const Route = createFileRoute('/_auth/dashboard')({ component: DashboardComponent })\n" +
  239. 'function DashboardComponent() {\n' +
  240. ' return <Link to="/posts">All posts</Link>\n' +
  241. '}\n'
  242. );
  243. // The precision floor: a pattern nothing serves, and a search-only navigation.
  244. write(
  245. 'src/routes/settings.tsx',
  246. "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" +
  247. "export const Route = createFileRoute('/settings')({ component: SettingsComponent })\n" +
  248. 'function SettingsComponent() {\n' +
  249. ' const navigate = useNavigate()\n' +
  250. ' function nowhere() {\n' +
  251. " navigate({ to: '/no-such-route' })\n" +
  252. ' }\n' +
  253. ' function filter() {\n' +
  254. ' navigate({ search: (old) => ({ ...old, page: 2 }) })\n' +
  255. ' }\n' +
  256. ' return <button onClick={nowhere} />\n' +
  257. '}\n'
  258. );
  259. cg = CodeGraph.initSync(tmpDir);
  260. await cg.indexAll();
  261. });
  262. afterAll(() => {
  263. cg?.close();
  264. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  265. });
  266. const route = (name: string): Node => {
  267. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  268. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  269. return r;
  270. };
  271. const sym = (name: string): Node => {
  272. const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
  273. if (!n) throw new Error(`no symbol ${name}`);
  274. return n;
  275. };
  276. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  277. const hrefs = (from: Node) =>
  278. navs(from)
  279. .map((e) => (e.metadata as Record<string, unknown>).href as string)
  280. .sort();
  281. it('names one route per address: the pathless layout is stripped, the index wins over the layout', () => {
  282. expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
  283. '/',
  284. '/dashboard',
  285. '/login',
  286. '/posts',
  287. '/posts/:postId',
  288. '/settings',
  289. ]);
  290. // `/posts` is the index page, not the `posts.route.tsx` layout beside it.
  291. const bound = cg.getOutgoingEdges(route('/posts').id).find((e) => e.kind === 'calls');
  292. expect(cg.getNode(bound!.target)?.name).toBe('PostsIndexComponent');
  293. // `_auth.dashboard.tsx` is the page at `/dashboard`.
  294. expect(route('/dashboard').filePath).toBe('src/routes/_auth.dashboard.tsx');
  295. });
  296. it('navigate({ to }) reaches the route the pattern names', () => {
  297. const submit = navs(sym('submit'));
  298. expect(submit).toHaveLength(1);
  299. expect(submit[0]!.target).toBe(route('/dashboard').id);
  300. expect(submit[0]!.metadata).toMatchObject({ href: '/dashboard', navMethod: 'navigate' });
  301. });
  302. it('a <Link to> names the route PATTERN, with its params beside it', () => {
  303. // `to="/posts/$postId"` is the route, not a filled URL.
  304. expect(hrefs(sym('IndexComponent'))).toEqual(['/login', '/posts/:postId']);
  305. const link = navs(sym('IndexComponent')).find((e) => e.target === route('/posts/:postId').id)!;
  306. expect(link.provenance).toBe('heuristic');
  307. expect(link.metadata).toMatchObject({ synthesizedBy: 'tanstack-link', href: '/posts/:postId', navMethod: 'link' });
  308. expect(hrefs(sym('DashboardComponent'))).toEqual(['/posts']);
  309. });
  310. it('a pattern nothing serves, and a navigation that only changes the search, are left unresolved', () => {
  311. expect(navs(sym('nowhere'))).toEqual([]);
  312. expect(navs(sym('filter'))).toEqual([]);
  313. });
  314. it('lands on the Screens tab as transitions between screens', async () => {
  315. const screens = await buildScreens(cg, tmpDir);
  316. expect(screens.routed).toBe(true);
  317. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  318. expect(at('/posts').component?.name).toBe('PostsIndexComponent');
  319. const toPost = screens.links.find((l) => l.from === at('/').id && l.to === at('/posts/:postId').id)!;
  320. expect(toPost).toBeDefined();
  321. expect(toPost.sites[0]).toMatchObject({ href: '/posts/:postId' });
  322. const signIn = screens.links.find((l) => l.from === at('/login').id && l.to === at('/dashboard').id)!;
  323. expect(signIn).toBeDefined();
  324. expect(signIn.via.map((v) => v.name)).toEqual(['submit']);
  325. expect(signIn.when).toBe('ok');
  326. expect(screens.dropped).toBe(0);
  327. });
  328. });