mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
37bcd5c0ae
- Route tables compose nested paths: a child's `path` is relative to the
path-bearing routes around it (`children` of a data router, nested
`<Route>` elements), absolute ones reset. `lazy: () => import('./x')`
(object and JSX) renders the module's default export (`Component` export
as fallback) via a claimed `lazy-import:` ref. `path: paths.app.root.path`
keeps its parts on the node's signature and is named in postExtract from
the constant's object literal (found through the file's import). The
route keeps its extracted id; `isReactRouterRoute` accepts it. An element
wrapped in parentheses or a guard (`<ProtectedRoute>`, `<Suspense>`,
`*Provider`, `*Guard`) renders the first element inside.
- Import aliases: the tsconfig / jsconfig nearest the importing file that
declares `paths` is tried before the root's, so each monorepo app resolves
its own `@/*` / `~/*`.
A/B (edges removed / added): bulletproof-react -43/+367 (0 → 9 named,
linked routes; cross-app imports fixed), react-native-reusables -103/+193,
kit -184/+218, trpc -45/+71 (examples' `~/…` now their own), create-t3-turbo
-1/+13; excalidraw, halo, obytes, realworld byte-identical. #1348's nested
fixtures now expect composed paths (`/dashboard/settings`, `/data/prefs`).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
695 lines
30 KiB
TypeScript
695 lines
30 KiB
TypeScript
/**
|
||
* React Router as a Screens app (`src/resolution/frameworks/react-router.ts`,
|
||
* `src/resolution/react-router-synthesizer.ts`): `<Route path>` routes bound
|
||
* to their screens by `frameworks/react.ts`, and the navigation half — the
|
||
* `history.push` / `navigate` / `redirect` calls and the `<Link to>` 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, afterEach } 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' +
|
||
' <Router>\n' +
|
||
" <Route path='/login' component={LoginScreen} />\n" +
|
||
" <Route path='/shipping' component={ShippingScreen} />\n" +
|
||
" <Route path='/payment' component={PaymentScreen} />\n" +
|
||
" <Route path='/placeorder' component={PlaceOrderScreen} />\n" +
|
||
" <Route path='/product/:id' component={ProductScreen} />\n" +
|
||
" <Route path='/cart/:id?' component={CartScreen} />\n" +
|
||
' </Router>\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 <form onSubmit={submitHandler}><CheckoutSteps step1 step2 step3 /></form>\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 <Link to='/shipping'>Continue</Link>\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 <form onSubmit={submitHandler} />\n' +
|
||
'}\n' +
|
||
'export default ShippingScreen\n'
|
||
);
|
||
write(
|
||
'frontend/src/screens/PlaceOrderScreen.js',
|
||
"import React from 'react'\nconst PlaceOrderScreen = () => <div>Order</div>\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 <button onClick={addToCart}>Add</button>\n' +
|
||
'}\n' +
|
||
'export default ProductScreen\n'
|
||
);
|
||
write(
|
||
'frontend/src/screens/CartScreen.js',
|
||
"import React from 'react'\nconst CartScreen = () => <div>Cart</div>\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' +
|
||
' <nav>\n' +
|
||
" <LinkContainer to='/cart'><span>Cart</span></LinkContainer>\n" +
|
||
" {step1 ? <LinkContainer to='/login'><span>Sign In</span></LinkContainer> : null}\n" +
|
||
" {step2 ? <NavLink to='/placeorder'>Place Order</NavLink> : null}\n" +
|
||
" <a href='https://example.com'>Elsewhere</a>\n" +
|
||
' </nav>\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;
|
||
};
|
||
// A handler written as `const submitHandler = () => {…}` inside a screen is a
|
||
// symbol of its own (#1669), so a navigation it makes is ITS edge — the same
|
||
// shape a `useCallback` handler has — and the screen reaches it by calling it.
|
||
const symIn = (name: string, file: string): Node => {
|
||
const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && n.filePath.endsWith(file));
|
||
if (!n) throw new Error(`no symbol ${name} in ${file}`);
|
||
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<string, unknown>).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');
|
||
const submit = symIn('submitHandler', 'PaymentScreen.js');
|
||
// The bounce-out is the component's own; the push on submit belongs to its handler.
|
||
expect(hrefs(payment)).toEqual(['/shipping']);
|
||
expect(hrefs(submit)).toEqual(['/placeorder']);
|
||
// `onSubmit={submitHandler}` is the screen's reference to it; the Screens
|
||
// walk below rides that hop.
|
||
expect(cg.getOutgoingEdges(payment.id).some((e) => e.target === submit.id && e.kind === 'references')).toBe(true);
|
||
const byHref = new Map([...navs(payment), ...navs(submit)].map((e) => [(e.metadata as Record<string, unknown>).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', () => {
|
||
const shippingSubmit = symIn('submitHandler', 'ShippingScreen.js');
|
||
expect(navs(shippingSubmit)[0]!.target).toBe(route('/payment').id);
|
||
expect(navs(shippingSubmit)[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
|
||
const product = navs(sym('addToCart'));
|
||
expect(product).toHaveLength(1);
|
||
expect(product[0]!.target).toBe(route('/cart/:id?').id);
|
||
expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' });
|
||
});
|
||
|
||
it('a <Link to> / <NavLink to> / <LinkContainer to> navigates from the component that renders it; an external <a> 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<string, unknown>).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' });
|
||
// The submit handler is the hop between the screen and the push.
|
||
expect(link.via.map((v) => v.name)).toEqual(['submitHandler']);
|
||
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' +
|
||
' <Router>\n' +
|
||
" <Route path='/search/:keyword' component={HomeScreen} exact />\n" +
|
||
" <Route path='/page/:pageNumber' component={HomeScreen} exact />\n" +
|
||
" <Route path='/' component={HomeScreen} exact />\n" +
|
||
" <Route path='/login' component={LoginScreen} />\n" +
|
||
" <Route path='/register' component={RegisterScreen} />\n" +
|
||
" <Route path='/product/:id' component={ProductScreen} />\n" +
|
||
' </Router>\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 <Link to={`/product/${keyword}`}>A product</Link>\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' +
|
||
' <Link to={redirect ? `/register?redirect=${redirect}` : \'/register\'}>Register</Link>\n' +
|
||
' )\n' +
|
||
'}\n' +
|
||
'export default LoginScreen\n'
|
||
);
|
||
write(
|
||
'src/screens/RegisterScreen.js',
|
||
"import React from 'react'\nconst RegisterScreen = () => <div>Register</div>\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' +
|
||
' <Link\n' +
|
||
' to={\n' +
|
||
' !isAdmin\n' +
|
||
' ? keyword\n' +
|
||
' ? `/search/${keyword}`\n' +
|
||
' : `/page/${x}`\n' +
|
||
" : '/register'\n" +
|
||
' }\n' +
|
||
' >\n' +
|
||
' {x}\n' +
|
||
' </Link>\n' +
|
||
')\n' +
|
||
'export default Paginate\n'
|
||
);
|
||
write(
|
||
'src/screens/ProductScreen.js',
|
||
"import React from 'react'\nconst ProductScreen = () => <div>Product</div>\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<string, unknown>).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)!;
|
||
// `<Link to={redirect ? … : '/register'}>` 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);
|
||
});
|
||
});
|
||
|
||
|
||
describe('react-router: route declaration boundaries (#1348)', () => {
|
||
let tmpDir: string;
|
||
let cg: CodeGraph | undefined;
|
||
|
||
afterEach(() => {
|
||
cg?.close();
|
||
cg = undefined;
|
||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
async function index(source: string, extension: string) {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-boundaries-'));
|
||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '18' } }));
|
||
fs.writeFileSync(path.join(tmpDir, `App.${extension}`), source);
|
||
cg = CodeGraph.initSync(tmpDir);
|
||
await cg.indexAll();
|
||
const routes = cg.getNodesByKind('route');
|
||
return {
|
||
paths: routes.map((route) => route.name).sort(),
|
||
bindings: routes.flatMap((route) => cg!.getOutgoingEdges(route.id)
|
||
.filter((edge) => edge.kind === 'references')
|
||
.map((edge) => `${route.name}->${cg!.getNode(edge.target)?.name}`)).sort(),
|
||
};
|
||
}
|
||
|
||
it.each(['tsx', 'jsx', 'js'])('keeps nested/index JSX routes and long attributes local in %s', async (extension) => {
|
||
const result = await index(`
|
||
import { Routes, Route } from 'react-router-dom';
|
||
function DashboardHome() { return null; }
|
||
function Settings() { return null; }
|
||
function Shell() { return null; }
|
||
const comparison = count<limit;
|
||
const fake = '<Route path="/fake" element={<DashboardHome/>}/>';
|
||
export function App() {
|
||
return <Routes>
|
||
<Route path="/dashboard">
|
||
<Route index element={<DashboardHome/>}/>
|
||
<Route path="settings" element={<Settings/>}/>
|
||
</Route>
|
||
<Route path="/empty"></Route>
|
||
<Route element={<Settings/>} path="/sibling"/>
|
||
<Route element={<Shell title="a > b"><Settings path="/nested"/></Shell>}
|
||
check={/}/.test('}')}
|
||
handle={{ text: 'path="/borrowed"', nested: { element: <DashboardHome/> } }}
|
||
title="${'x'.repeat(600)}" path="/long"/>
|
||
<Route path="/no-element" handle={{ element: <DashboardHome/> }}/>
|
||
<Route component={Settings} path="/legacy"/>
|
||
</Routes>;
|
||
}
|
||
`, extension);
|
||
expect(result).toEqual({
|
||
// A nested route's path is relative to its parent's (`settings` under `/dashboard`).
|
||
paths: ['/dashboard', '/dashboard/settings', '/empty', '/legacy', '/long', '/no-element', '/sibling'],
|
||
bindings: ['/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'],
|
||
});
|
||
});
|
||
|
||
it.each(['tsx', 'jsx', 'ts', 'js'])('pairs only direct data-router properties in either order in %s', async (extension) => {
|
||
const result = await index(`
|
||
import { createBrowserRouter } from 'react-router-dom';
|
||
function DataIndex() { return null; }
|
||
function DataSettings() { return null; }
|
||
const routes = createBrowserRouter([
|
||
{ path: '/data', children: [
|
||
{ index: true, Component: DataIndex },
|
||
{ Component: DataSettings, path: 'prefs' }
|
||
] },
|
||
{ path: '/empty' },
|
||
{ Component: DataSettings, path: '/sibling' },
|
||
{ path: '/metadata', handle: { Component: DataIndex } },
|
||
{ Component: DataSettings, handle: { path: '/not-own' } },
|
||
{ path: '/long', handle: { text: '${'x'.repeat(600)}' }, Component: DataSettings },
|
||
{ 'Component': DataSettings, /* path: '/fake' */ 'path': '/quoted' /* trailing comment */ },
|
||
{ path: '', Component: DataSettings }
|
||
]);
|
||
`, extension);
|
||
expect(result).toEqual({
|
||
paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'],
|
||
bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'],
|
||
});
|
||
});
|
||
|
||
it('keeps nested JSX and comma-containing expressions inside their data-router property', async () => {
|
||
const result = await index(`
|
||
import { createMemoryRouter } from 'react-router-dom';
|
||
function Shell() { return null; }
|
||
function Child() { return null; }
|
||
const router = createMemoryRouter([
|
||
{ element: <Shell title="a > b"><Child path="/fake"/>hello, world</Shell>,
|
||
handle: { text: "}, path: '/fake'", callback: () => ({ path: '/also-fake' }) }, path: '/shell' },
|
||
{ path: '/none', handle: { element: <Child/> } },
|
||
{ path: '/child', element: <Child/> }
|
||
]);
|
||
`, 'tsx');
|
||
expect(result).toEqual({ paths: ['/child', '/shell'], bindings: ['/child->Child', '/shell->Shell'] });
|
||
});
|
||
});
|
||
|
||
describe('react-router: v5 redirects and styled link wrappers', () => {
|
||
let root: string;
|
||
let cg: CodeGraph;
|
||
beforeAll(async () => {
|
||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-wrappers-'));
|
||
const files: Record<string, string> = {
|
||
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '*', 'react-router-dom': '^5.0.0', 'styled-components': '*' } }),
|
||
// react-boilerplate: the header's links are a styled Link, default-exported.
|
||
'app/components/Header/HeaderLink.js': `import { Link } from 'react-router-dom';
|
||
import styled from 'styled-components';
|
||
|
||
export default styled(Link)\`
|
||
color: #41addd;
|
||
\`;
|
||
`,
|
||
'app/components/Header/index.js': `import React from 'react';
|
||
import HeaderLink from './HeaderLink';
|
||
|
||
export default function Header() {
|
||
return (
|
||
<nav>
|
||
<HeaderLink to="/">Home</HeaderLink>
|
||
<HeaderLink to="/features">Features</HeaderLink>
|
||
</nav>
|
||
);
|
||
}
|
||
`,
|
||
'app/components/Nav.js': `import React from 'react';
|
||
import { NavLink } from 'react-router-dom';
|
||
import styled from 'styled-components';
|
||
|
||
const MenuLink = styled(NavLink)\`
|
||
padding: 4px;
|
||
\`;
|
||
|
||
export default function Nav() {
|
||
return <MenuLink to="/features">Features</MenuLink>;
|
||
}
|
||
`,
|
||
// takenote: a guard renders v5's <Redirect to>.
|
||
'app/router/PrivateRoute.js': `import React from 'react';
|
||
import { Route, Redirect } from 'react-router-dom';
|
||
|
||
export default function PrivateRoute({ component: Component, ...rest }) {
|
||
return <Route {...rest} render={(props) => (rest.isAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} />;
|
||
}
|
||
`,
|
||
'app/containers/App.js': `import React from 'react';
|
||
import { Switch, Route } from 'react-router-dom';
|
||
import HomePage from './HomePage';
|
||
import FeaturePage from './FeaturePage';
|
||
|
||
export default function App() {
|
||
return (
|
||
<Switch>
|
||
<Route exact path="/" component={HomePage} />
|
||
<Route path="/features" component={FeaturePage} />
|
||
</Switch>
|
||
);
|
||
}
|
||
`,
|
||
'app/containers/HomePage.js': `import React from 'react';
|
||
export default function HomePage() { return <h1>Home</h1>; }
|
||
`,
|
||
'app/containers/FeaturePage.js': `import React from 'react';
|
||
export default function FeaturePage() { return <h1>Features</h1>; }
|
||
`,
|
||
};
|
||
for (const [rel, content] of Object.entries(files)) {
|
||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||
fs.writeFileSync(path.join(root, rel), content);
|
||
}
|
||
cg = await CodeGraph.init(root, { index: true });
|
||
});
|
||
afterAll(() => {
|
||
cg?.close();
|
||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||
});
|
||
|
||
const navsFrom = (name: string) => {
|
||
const from = cg.getNodesByName(name).find((n) => n.kind === 'function' || n.kind === 'component')!;
|
||
return cg
|
||
.getOutgoingEdges(from.id)
|
||
.filter((e) => e.kind === 'navigates')
|
||
.map((e) => `${(e.metadata as Record<string, unknown>).navMethod} ${cg.getNode(e.target)!.name}`)
|
||
.sort();
|
||
};
|
||
|
||
it('a styled(Link) wrapper, imported or local, is a link', () => {
|
||
expect(navsFrom('Header')).toEqual(['link /', 'link /features']);
|
||
expect(navsFrom('Nav')).toEqual(['link /features']);
|
||
});
|
||
|
||
it('v5’s <Redirect to> navigates', () => {
|
||
expect(navsFrom('PrivateRoute')).toEqual(['redirect /']);
|
||
});
|
||
});
|