diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fb6d9602..4c4bb38e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+- React Router apps now show navigation written as React Router v5's `` and through a styled link, like react-boilerplate's `HeaderLink = styled(Link)` used as ``. Apps built this way had routes but no links between their screens.
- SvelteKit routes are now named by the address a browser asks for. A `(group)` folder like `(app)` or `(marketing)` is no longer part of a route's path, and a parameter with a matcher, like `[id=integer]`, is just `:id`. So `goto('/blocks')` and `` now reach a page that lives in `src/routes/(app)/blocks/`, where before they reached nothing and the app's screens showed no navigation. Re-index SvelteKit projects after upgrading.
- Vue Router apps built like vue-element-admin, vue-admin-template or vben now have their routes and screens. Routes declared in a named table (`export const constantRoutes = [...]`, `const routes: RouteRecordRaw[] = [...]`), in per-module route files, or through `new Router(...)` are now read. Child routes are joined onto their parent's path, and the parent's component is the layout around them. A lazily loaded view (`() => import('@/views/dashboard/index')`) is bound to the file it names, and `this.$router.push(...)` counts as navigation. Nuxt's file-based routes now come only from a Nuxt app, so a plain Vue app's `pages/` folder no longer turns into made-up screens, and a Nuxt app at the root of its repository now gets its routes. Re-index Vue projects after upgrading.
- In a monorepo with an Expo app next to a Next.js app, like create-t3-turbo or tamagui's starter, each router now reads only its own app. Expo Router used to read the Next.js app's files too, so `layout.tsx`, `page.tsx`, a `_components` folder and an API route showed up as screens named `/layout`, `/page` or `/_components/posts`, and a shared package's files became screens as well. Re-index such monorepos after upgrading.
diff --git a/README.md b/README.md
index a02bfc030..23a046f54 100644
--- a/README.md
+++ b/README.md
@@ -342,7 +342,7 @@ These frameworks additionally emit **`navigates`** edges: the function that send
|---|---|---|
| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href |
| **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `` and internal `` |
-| **React Router** | `` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `` / `` / `` / react-router-bootstrap's `` |
+| **React Router** | `` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `` / `` / `` / v5's `` / react-router-bootstrap's ``, and a `styled(Link)` wrapper |
| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `` / `` — where `to` is the route PATTERN and the values ride beside it in `params` |
| **Vue Router** / **Nuxt** | `createRouter({ routes: [...] })` / `new Router(...)` and the route tables it's given (`export const constantRoutes = [...]`, per-module route files), with the view each entry names — a lazy `() => import(…)` bound to its file — and `children` joined onto their parent's path, the parent being the layout around them; plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `this.$router.push`, Nuxt's `navigateTo`, `` / `` / `` — **by route name** (`push({ name: 'profile' })`) as well as by path |
| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`, `[id=matcher]` → `:id`, `(group)` folders stripped), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `` that is a link in a SvelteKit app |
diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts
index 70ea8266c..fd4c9d7e9 100644
--- a/__tests__/react-router.test.ts
+++ b/__tests__/react-router.test.ts
@@ -593,3 +593,101 @@ describe('react-router: route declaration boundaries (#1348)', () => {
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 = {
+ '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 (
+
+ );
+}
+`,
+ '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 Features;
+}
+`,
+ // takenote: a guard renders v5's .
+ 'app/router/PrivateRoute.js': `import React from 'react';
+import { Route, Redirect } from 'react-router-dom';
+
+export default function PrivateRoute({ component: Component, ...rest }) {
+ return (rest.isAuthenticated ? : )} />;
+}
+`,
+ '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 (
+
+
+
+
+ );
+}
+`,
+ 'app/containers/HomePage.js': `import React from 'react';
+export default function HomePage() { return Home
; }
+`,
+ 'app/containers/FeaturePage.js': `import React from 'react';
+export default function FeaturePage() { return Features
; }
+`,
+ };
+ 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).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 navigates', () => {
+ expect(navsFrom('PrivateRoute')).toEqual(['redirect /']);
+ });
+});
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index e0be9a92b..fcb12619c 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -38,7 +38,7 @@ guessed.
|---|---|---|---|---|
| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
-| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges) |
+| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``); bulletproof-react's `paths.x.path` constants and `lazy` routes are not read yet |
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) |
diff --git a/src/resolution/react-router-synthesizer.ts b/src/resolution/react-router-synthesizer.ts
index a0706219e..3787b625a 100644
--- a/src/resolution/react-router-synthesizer.ts
+++ b/src/resolution/react-router-synthesizer.ts
@@ -6,6 +6,8 @@
*
* … // react-router-bootstrap
* … // v5's object form
+ * // v5
+ * // HeaderLink = styled(Link)
*
* A JSX attribute is not a call, so the extractor records no reference for it
* and the resolver in `frameworks/react-router.ts` — which binds
@@ -27,7 +29,7 @@
* attribute (`to`, not `href`) and the other table.
*/
-import type { Edge } from '../types';
+import type { Edge, Language } from '../types';
import type { ResolutionContext } from './types';
import type { MaybeYield } from './cooperative-yield';
import { stripCommentsForRegex } from './strip-comments';
@@ -37,14 +39,75 @@ import { matchBracket } from './frameworks/object-literal';
import { destinationsForHref } from './frameworks/nextjs';
import { reactRouterTable } from './frameworks/react-router';
import { enclosingFn, makeLineAt } from './synth-utils';
+import { resolveImportPath } from './import-resolver';
const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
-/** The tags that carry a route as a `to` attribute, the attribute anywhere in the tag. */
-const LINK_TAG = /<(Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b([^>]*?)\bto\s*=\s*(?:"([^"]*)"|'([^']*)'|(?=\{))/g;
+/** The tags that carry a route as a `to` attribute. */
+const LINK_TAGS = ['Link', 'NavLink', 'Navigate', 'Redirect', 'LinkContainer', 'IndexLinkContainer'];
-/** A tag this pass could possibly match — the cheap prefilter before stripping comments. */
-const HAS_LINK_TAG = /<(?:Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b/;
+/** `` for any of `tags`, the attribute anywhere in the tag. */
+function linkTagPattern(tags: readonly string[]): RegExp {
+ return new RegExp(`<(${tags.map((t) => t.replace(/[$]/g, '\\$&')).join('|')})\\b([^>]*?)\\bto\\s*=\\s*(?:"([^"]*)"|'([^']*)'|(?=\\{))`, 'g');
+}
+
+const LINK_TAG = linkTagPattern(LINK_TAGS);
+
+/** A file that imports React Router's own components. */
+const ROUTER_IMPORT = /\bfrom\s*['"]react-router(?:-dom)?['"]/;
+
+/** `const HeaderLink = styled(Link)` — a styled wrapper is the link it wraps. */
+const STYLED_LINK_CONST = /\b(?:export\s+)?(?:const|let)\s+([A-Z][\w$]*)\s*=\s*styled\s*\(\s*(?:Link|NavLink)\s*\)/g;
+const STYLED_LINK_DEFAULT = /\bexport\s+default\s+styled\s*\(\s*(?:Link|NavLink)\s*\)/;
+
+interface LinkWrappers {
+ /** File → the wrapper components it declares by name. */
+ named: Map>;
+ /** Files whose default export is a wrapper. */
+ defaults: Set;
+}
+
+/**
+ * Every styled-components / emotion wrapper of `Link` or `NavLink` in the
+ * project — react-boilerplate's header links are `export default
+ * styled(Link)`…``, used as `` from another file.
+ */
+function linkWrappers(ctx: ResolutionContext): LinkWrappers {
+ const named = new Map>();
+ const defaults = new Set();
+ for (const file of ctx.getAllFiles()) {
+ if (!JSX_FILE.test(file) || isTestPath(file)) continue;
+ if (ctx.fileContains && !ctx.fileContains(file, 'styled')) continue;
+ const source = ctx.readFile(file);
+ if (!source || !/\bstyled\s*\(\s*(?:Link|NavLink)\s*\)/.test(source) || !ROUTER_IMPORT.test(source)) continue;
+ if (STYLED_LINK_DEFAULT.test(source)) defaults.add(file);
+ STYLED_LINK_CONST.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = STYLED_LINK_CONST.exec(source)) !== null) {
+ let set = named.get(file);
+ if (!set) named.set(file, (set = new Set()));
+ set.add(m[1]!);
+ }
+ }
+ return { named, defaults };
+}
+
+/** The link tags `file` can write: React Router's own and the wrappers in scope there. */
+function linkTagsFor(file: string, wrappers: LinkWrappers, ctx: ResolutionContext): string[] {
+ const tags = [...LINK_TAGS, ...(wrappers.named.get(file) ?? [])];
+ if (wrappers.named.size > 0 || wrappers.defaults.size > 0) {
+ for (const m of ctx.getImportMappings(file, languageOf(file))) {
+ const target = resolveImportPath(m.source, file, languageOf(file), ctx);
+ if (!target) continue;
+ if ((m.isDefault && wrappers.defaults.has(target)) || wrappers.named.get(target)?.has(m.exportedName)) tags.push(m.localName);
+ }
+ }
+ return tags;
+}
+
+function languageOf(file: string): Language {
+ return /\.tsx$/.test(file) ? 'tsx' : /\.[cm]?ts$/.test(file) ? 'typescript' : /\.jsx$/.test(file) ? 'jsx' : 'javascript';
+}
/** Links a single component may carry before it is a navigation menu, not a decision. */
const MAX_LINKS_PER_COMPONENT = 24;
@@ -55,6 +118,7 @@ export async function reactRouterLinkEdges(ctx: ResolutionContext, onYield: Mayb
const edges: Edge[] = [];
const seen = new Set();
const perComponent = new Map();
+ const wrappers = linkWrappers(ctx);
let scanned = 0;
for (const file of ctx.getAllFiles()) {
if (!JSX_FILE.test(file) || isTestPath(file)) continue;
@@ -62,13 +126,16 @@ export async function reactRouterLinkEdges(ctx: ResolutionContext, onYield: Mayb
if (!routes || routes.exact.size === 0) continue;
if ((++scanned & 63) === 0) await onYield();
const source = ctx.readFile(file);
- if (!source || !HAS_LINK_TAG.test(source)) continue;
+ if (!source || !/\bto\s*=/.test(source)) continue;
+ const tags = linkTagsFor(file, wrappers, ctx);
+ const pattern = tags.length === LINK_TAGS.length ? LINK_TAG : linkTagPattern(tags);
+ if (!new RegExp(`<(?:${tags.join('|')})\\b`).test(source)) continue;
const safe = stripCommentsForRegex(source, 'typescript');
const nodes = ctx.getNodesInFile(file);
const lineOf = makeLineAt(safe, 1);
- LINK_TAG.lastIndex = 0;
+ pattern.lastIndex = 0;
let m: RegExpExecArray | null;
- while ((m = LINK_TAG.exec(safe)) !== null) {
+ while ((m = pattern.exec(safe)) !== null) {
const tag = m[1]!;
const quoted: string | null = m[3] ?? m[4] ?? null;
let href: HrefLiteral | null;
@@ -109,7 +176,7 @@ export async function reactRouterLinkEdges(ctx: ResolutionContext, onYield: Mayb
metadata: {
synthesizedBy: 'react-router-link',
href: arm.display,
- navMethod: tag === 'Navigate' ? 'navigate' : 'link',
+ navMethod: tag === 'Navigate' ? 'navigate' : tag === 'Redirect' ? 'redirect' : 'link',
registeredAt: `${file}:${line}`,
},
});