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}`, }, });