mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 /']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user