fix(react-router): v5 <Redirect to> and styled(Link) wrappers navigate (#2132)

react-boilerplate's header links are `export default styled(Link)`…``,
used as `<HeaderLink to="/features">`; takenote's guards render v5's
`<Redirect to="/" />`. Neither tag was read, so both apps had routes and
no links between their screens.

The link synthesizer now reads `<Redirect to>` (navMethod `redirect`) and
any styled-components / emotion wrapper of `Link` / `NavLink` — declared in
the file, or imported from a file that declares or default-exports one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 06:55:41 +00:00
committed by GitHub
parent b62da73550
commit 4423891e76
5 changed files with 177 additions and 11 deletions
+1
View File
@@ -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 `<Redirect to="/login" />` and through a styled link, like react-boilerplate's `HeaderLink = styled(Link)` used as `<HeaderLink to="/features">`. 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 `<a href="/blocks">` 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.
+1 -1
View File
@@ -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, `<Link href>` and internal `<a href>` |
| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` |
| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / v5's `<Redirect to>` / react-router-bootstrap's `<LinkContainer to>`, 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 `<Outlet/>` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `<Link to>` / `<Navigate 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`, `<router-link>` / `<RouterLink>` / `<NuxtLink>` — **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 `<a href>` that is a link in a SvelteKit app |
+98
View File
@@ -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<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 /']);
});
});
+1 -1
View File
@@ -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 `<Redirect>`); 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) |
+76 -9
View File
@@ -6,6 +6,8 @@
* <Navigate to="/login" replace />
* <LinkContainer to="/payment">…</LinkContainer> // react-router-bootstrap
* <Link to={{ pathname: '/shipping' }}>…</Link> // v5's object form
* <Redirect to="/login" /> // v5
* <HeaderLink to="/features"> // 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/;
/** `<Tag … to=…>` 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<string, Set<string>>;
/** Files whose default export is a wrapper. */
defaults: Set<string>;
}
/**
* Every styled-components / emotion wrapper of `Link` or `NavLink` in the
* project — react-boilerplate's header links are `export default
* styled(Link)`…``, used as `<HeaderLink to="/features">` from another file.
*/
function linkWrappers(ctx: ResolutionContext): LinkWrappers {
const named = new Map<string, Set<string>>();
const defaults = new Set<string>();
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<string>();
const perComponent = new Map<string, number>();
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}`,
},
});