/** * React Router as a Screens app (`src/resolution/frameworks/react-router.ts`, * `src/resolution/react-router-synthesizer.ts`): `` routes bound * to their screens by `frameworks/react.ts`, and the navigation half — the * `history.push` / `navigate` / `redirect` calls and the `` markup * that carry a user from one screen to the next. * * The fixture is proshop's shape on purpose: a `frontend/` workspace whose * routes live in `src/App.js` and whose screens live in `src/screens/`, which * is what the app-root gate has to get right. Mirrors `nextjs.test.ts`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; import { buildScreens } from '../src/ui-server/api/screens'; import { buildSteps } from '../src/ui-server/api/steps'; import { reactRouterRoot, reactRouterNavVerb } from '../src/resolution/frameworks/react-router'; import type { Node } from '../src/types'; // ============================================================================= // The app root a route file owns // ============================================================================= describe('react-router: reactRouterRoot', () => { it.each([ ['frontend/src/App.js', 'frontend/'], ['src/App.tsx', ''], ['apps/web/src/routes/index.tsx', 'apps/web/'], ['client/App.jsx', 'client/'], ['App.jsx', ''], ])('%s → %s', (file, root) => { expect(reactRouterRoot(file)).toBe(root); }); }); describe('react-router: reactRouterNavVerb', () => { it.each([ ['history.push', 'push'], ['history.replace', 'replace'], ['navigate', 'navigate'], ['router.navigate', 'navigate'], ['redirect', 'redirect'], ])('%s → %s', (name, verb) => { expect(reactRouterNavVerb(name)).toBe(verb); }); it.each(['push', 'replace', 'paths.push', 'list.replace', 'items.navigate', 'go', 'goBack'])( '%s is not a navigation — an unqualified push is an array’s', (name) => { expect(reactRouterNavVerb(name)).toBeNull(); } ); }); // ============================================================================= // The whole picture, indexed // ============================================================================= describe('react-router: a routed app end to end', () => { let tmpDir: string; let cg: CodeGraph; function write(rel: string, content: string): void { const full = path.join(tmpDir, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, content); } beforeAll(async () => { await initGrammars(); await loadAllGrammars(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-react-router-')); write('package.json', JSON.stringify({ name: 'shop', private: true })); write( 'frontend/package.json', JSON.stringify({ name: 'frontend', dependencies: { react: '18', 'react-router-dom': '5', 'react-router-bootstrap': '0.26' }, }) ); write( 'frontend/src/App.js', "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + "import LoginScreen from './screens/LoginScreen'\n" + "import ShippingScreen from './screens/ShippingScreen'\n" + "import PaymentScreen from './screens/PaymentScreen'\n" + "import PlaceOrderScreen from './screens/PlaceOrderScreen'\n" + "import ProductScreen from './screens/ProductScreen'\n" + "import CartScreen from './screens/CartScreen'\n" + 'const App = () => (\n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ')\n' + 'export default App\n' ); // The screen the picture was wrong on: a guarded bounce out, and a push on // submit after the store action. Both are `history.push` with a literal. write( 'frontend/src/screens/PaymentScreen.js', "import React, { useState } from 'react'\n" + "import { useDispatch, useSelector } from 'react-redux'\n" + "import CheckoutSteps from '../components/CheckoutSteps'\n" + "import { savePaymentMethod } from '../actions/cartActions'\n" + 'const PaymentScreen = ({ history }) => {\n' + ' const cart = useSelector((state) => state.cart)\n' + ' const { shippingAddress } = cart\n' + ' if (!shippingAddress.address) {\n' + " history.push('/shipping')\n" + ' }\n' + " const [paymentMethod, setPaymentMethod] = useState('PayPal')\n" + ' const dispatch = useDispatch()\n' + ' const submitHandler = (e) => {\n' + ' e.preventDefault()\n' + ' dispatch(savePaymentMethod(paymentMethod))\n' + " history.push('/placeorder')\n" + ' }\n' + ' return
\n' + '}\n' + 'export default PaymentScreen\n' ); // A computed destination is not a destination: `redirect` is read off the // query string, so nothing static names a route. write( 'frontend/src/screens/LoginScreen.js', "import React, { useEffect } from 'react'\n" + "import { Link } from 'react-router-dom'\n" + 'const LoginScreen = ({ location, history, userInfo }) => {\n' + " const redirect = location.search ? location.search.split('=')[1] : '/'\n" + ' useEffect(() => {\n' + ' if (userInfo) {\n' + ' history.push(redirect)\n' + ' }\n' + ' }, [history, userInfo, redirect])\n' + " return Continue\n" + '}\n' + 'export default LoginScreen\n' ); write( 'frontend/src/screens/ShippingScreen.js', "import React from 'react'\n" + 'const ShippingScreen = ({ history }) => {\n' + ' const submitHandler = () => {\n' + " history.replace('/payment')\n" + ' }\n' + ' return
\n' + '}\n' + 'export default ShippingScreen\n' ); write( 'frontend/src/screens/PlaceOrderScreen.js', "import React from 'react'\nconst PlaceOrderScreen = () =>
Order
\nexport default PlaceOrderScreen\n" ); // v6's hook, and a template hole that has to land on the `:id` route. write( 'frontend/src/screens/ProductScreen.js', "import React from 'react'\n" + "import { useNavigate } from 'react-router-dom'\n" + 'const ProductScreen = ({ match }) => {\n' + ' const navigate = useNavigate()\n' + ' const addToCart = () => {\n' + ' navigate(`/cart/${match.params.id}`)\n' + ' }\n' + ' return \n' + '}\n' + 'export default ProductScreen\n' ); write( 'frontend/src/screens/CartScreen.js', "import React from 'react'\nconst CartScreen = () =>
Cart
\nexport default CartScreen\n" ); // Navigation written as markup, including react-router-bootstrap's wrapper. write( 'frontend/src/components/CheckoutSteps.js', "import React from 'react'\n" + "import { NavLink } from 'react-router-dom'\n" + "import { LinkContainer } from 'react-router-bootstrap'\n" + 'const CheckoutSteps = ({ step1, step2 }) => (\n' + ' \n' + ')\n' + 'export default CheckoutSteps\n' ); write( 'frontend/src/actions/cartActions.js', 'export const savePaymentMethod = (data) => (dispatch) => {\n' + " dispatch({ type: 'CART_SAVE_PAYMENT_METHOD', payload: data })\n" + " localStorage.setItem('paymentMethod', JSON.stringify(data))\n" + '}\n' ); // The precision floor: an array's `push` with a string that IS a route. write( 'frontend/src/utils/breadcrumbs.js', 'export const trail = () => {\n' + ' const paths = []\n' + " paths.push('/placeorder')\n" + ' return paths\n' + '}\n' ); cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); }); afterAll(() => { cg?.close(); if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); const route = (name: string): Node => { const r = cg.getNodesByKind('route').find((r) => r.name === name); if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`); return r; }; const sym = (name: string): Node => { const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import'); if (!n) throw new Error(`no symbol ${name}`); return n; }; const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates'); const hrefs = (from: Node) => navs(from) .map((e) => (e.metadata as Record).href as string) .sort(); it('names every route and binds it to its screen', () => { expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([ '/cart/:id?', '/login', '/payment', '/placeorder', '/product/:id', '/shipping', ]); const bound = cg.getOutgoingEdges(route('/payment').id).find((e) => e.kind === 'references'); expect(cg.getNode(bound!.target)?.name).toBe('PaymentScreen'); }); it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => { const payment = sym('PaymentScreen'); expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']); const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record).href, e])); expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id); expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id); expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' }); }); it('history.replace navigates, and v6’s navigate() with a template hole reaches the :id route', () => { expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id); expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' }); const product = navs(sym('ProductScreen')); expect(product).toHaveLength(1); expect(product[0]!.target).toBe(route('/cart/:id?').id); expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' }); }); it('a / / navigates from the component that renders it; an external does not', () => { expect(hrefs(sym('LoginScreen'))).toEqual(['/shipping']); const link = navs(sym('LoginScreen'))[0]!; expect(link.provenance).toBe('heuristic'); expect(link.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/shipping', navMethod: 'link' }); // `/cart` reaches `/cart/:id?` — an optional parameter serves the bare path too. expect(hrefs(sym('CheckoutSteps'))).toEqual(['/cart', '/login', '/placeorder']); }); it('a computed destination is left unresolved, and an array’s push is never claimed', () => { // `history.push(redirect)` — the path comes off the query string. expect(navs(sym('LoginScreen')).every((e) => (e.metadata as Record).synthesizedBy === 'react-router-link')).toBe(true); expect(navs(sym('trail'))).toEqual([]); }); it('lands on the Screens tab as transitions between screens', async () => { const screens = await buildScreens(cg, tmpDir); expect(screens.routed).toBe(true); const at = (p: string) => screens.screens.find((s) => s.path === p)!; const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!; expect(link).toBeDefined(); expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' }); expect(link.via).toEqual([]); expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined(); expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined(); }); it('the payment screen’s Steps picture draws the pages it leads to, not just its store write', async () => { const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/payment').id })); const anchor = p.steps.find((s) => s.anchor)!; expect(anchor.sub).toBe('PaymentScreen'); const store = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'storage')!; expect(store.label).toContain("localStorage.setItem('paymentMethod'"); // Its own two pushes, plus the link back to sign-in its checkout nav renders. const to = p.steps.filter((s) => s.kind === 'screen' && !s.anchor).map((s) => s.screen?.path).sort(); expect(to).toEqual(['/cart/:id?', '/login', '/placeorder', '/shipping']); const placeorder = p.steps.find((s) => s.screen?.path === '/placeorder')!; expect(placeorder.cut).toBe('screen'); const push = p.links.find((l) => l.to === placeorder.id)!; expect(push.kind).toBe('navigates'); expect(push.sites.map((site) => site.text)).toContain('push /placeorder'); // The bounce out is drawn with the condition that sends the user there. const shipping = p.steps.find((s) => s.screen?.path === '/shipping')!; const bounce = p.links.find((l) => l.to === shipping.id)!; expect(bounce.sites[0]).toMatchObject({ text: 'push /shipping', when: '!shippingAddress.address' }); }); }); // ============================================================================= // One component at several addresses, and the destinations a login writes // ============================================================================= describe('react-router: the shapes proshop is written in', () => { let tmpDir: string; let cg: CodeGraph; function write(rel: string, content: string): void { const full = path.join(tmpDir, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, content); } beforeAll(async () => { await initGrammars(); await loadAllGrammars(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-shapes-')); write('package.json', JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } })); // One component, four addresses — proshop renders HomeScreen at all four. write( 'src/App.js', "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + "import HomeScreen from './screens/HomeScreen'\n" + "import LoginScreen from './screens/LoginScreen'\n" + "import RegisterScreen from './screens/RegisterScreen'\n" + "import ProductScreen from './screens/ProductScreen'\n" + 'const App = () => (\n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ')\n' + 'export default App\n' ); write( 'src/screens/HomeScreen.js', "import React from 'react'\n" + "import { Link } from 'react-router-dom'\n" + 'const HomeScreen = ({ match }) => {\n' + ' const keyword = match.params.keyword\n' + ' return A product\n' + '}\n' + 'export default HomeScreen\n' ); // The destination every react-router app writes for "where to after login". write( 'src/screens/LoginScreen.js', "import React, { useEffect } from 'react'\n" + "import { Link } from 'react-router-dom'\n" + 'const LoginScreen = ({ location, history, userInfo }) => {\n' + " const redirect = location.search ? location.search.split('=')[1] : '/'\n" + ' useEffect(() => {\n' + ' if (userInfo) {\n' + ' history.push(redirect)\n' + ' }\n' + ' }, [history, userInfo, redirect])\n' + ' return (\n' + ' Register\n' + ' )\n' + '}\n' + 'export default LoginScreen\n' ); write( 'src/screens/RegisterScreen.js', "import React from 'react'\nconst RegisterScreen = () =>
Register
\nexport default RegisterScreen\n" ); // proshop's paginator: one link, three destinations, chosen at runtime. write( 'src/components/Paginate.js', "import React from 'react'\n" + "import { Link } from 'react-router-dom'\n" + 'const Paginate = ({ isAdmin, keyword, x }) => (\n' + ' \n' + ' {x}\n' + ' \n' + ')\n' + 'export default Paginate\n' ); write( 'src/screens/ProductScreen.js', "import React from 'react'\nconst ProductScreen = () =>
Product
\nexport default ProductScreen\n" ); cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); }); afterAll(() => { cg?.close(); if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); const route = (name: string): Node => { const r = cg.getNodesByKind('route').find((r) => r.name === name); if (!r) throw new Error(`no route ${name}`); return r; }; const sym = (name: string): Node => { const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import'); if (!n) throw new Error(`no symbol ${name}`); return n; }; const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates'); it('a `to={cond ? … : …}` is read, because markup uses the same reader a push does', () => { const toRegister = navs(sym('LoginScreen')).find((e) => e.target === route('/register').id); expect(toRegister).toBeDefined(); // Both arms name `/register`; the href shows the one as written. expect(toRegister!.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/register?redirect=${…}' }); }); it('a destination whose other arm is computed still names where it goes', () => { // `const redirect = location.search ? location.search.split('=')[1] : '/'` // then `history.push(redirect)` — `/` is where this lands by default. const home = navs(sym('LoginScreen')).find((e) => e.target === route('/').id); expect(home).toBeDefined(); expect(home!.metadata).toMatchObject({ href: '/', navMethod: 'push' }); }); it('a destination written as a three-way choice draws all three, each with the arm it took', () => { const from = navs(sym('Paginate')); const byTarget = new Map(from.map((e) => [e.target, (e.metadata as Record).href])); expect(byTarget.get(route('/search/:keyword').id)).toBe('/search/${…}'); expect(byTarget.get(route('/page/:pageNumber').id)).toBe('/page/${…}'); expect(byTarget.get(route('/register').id)).toBe('/register'); // Each edge names the path it took, not the first arm's. expect(from).toHaveLength(3); }); it('a link written under a condition carries that condition, and reads as a link', async () => { const screens = await buildScreens(cg, tmpDir); const at = (p: string) => screens.screens.find((s) => s.path === p)!; // `` is markup: the destination is // written right there, so it is a `link`, not a helper's `return` value. const toRegister = screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)!; expect(toRegister.sites[0]!.method).toBe('link'); }); it('a component rendered at several addresses gives its navigation to EVERY one', async () => { const screens = await buildScreens(cg, tmpDir); const at = (p: string) => screens.screens.find((s) => s.path === p)!; // HomeScreen serves three routes; all three lead to the product page. for (const from of ['/', '/search/:keyword', '/page/:pageNumber']) { expect(screens.links.find((l) => l.from === at(from).id && l.to === at('/product/:id').id)).toBeDefined(); } // …and none of them is left as a screen you can reach but never leave. for (const s of screens.screens) { if (s.path === '/product/:id' || s.path === '/register') continue; expect(screens.links.some((l) => l.from === s.id)).toBe(true); } expect(screens.dropped).toBe(0); }); });