Unverified 提交 374bebce authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

feat: remove * and fix crash; refactor useParseRouter hook and add tests (#2780)

## Summary - Refactor `useParseRouter` hook to improve code organization and maintainability - Add comprehensive unit tests for the `useParseRouter` hook covering navigation state parsing, route matching, and edge cases ## Test plan - Run `npm test` to verify all 842 tests pass - The new tests in `src/__tests__/useParseRouter.test.ts` cover various navigation scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2780" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 822a998f
import { describe, it, expect } from "vitest";
import {
buildRouteLabel,
parseRoutesFromRouterFile,
parseRoutesFromNextFiles,
} from "@/hooks/useParseRouter";
describe("buildRouteLabel", () => {
it("should return 'Home' for root path", () => {
expect(buildRouteLabel("/")).toBe("Home");
});
it("should capitalize the last segment", () => {
expect(buildRouteLabel("/about")).toBe("About");
expect(buildRouteLabel("/contact-us")).toBe("Contact us");
expect(buildRouteLabel("/user_profile")).toBe("User profile");
});
it("should skip dynamic segments with colons", () => {
expect(buildRouteLabel("/users/:id")).toBe("Users");
expect(buildRouteLabel("/users/:id/posts")).toBe("Posts");
});
it("should handle deeply nested paths", () => {
expect(buildRouteLabel("/admin/settings/security")).toBe("Security");
});
});
describe("parseRoutesFromRouterFile", () => {
it("should parse simple routes from JSX", () => {
const content = `
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(3);
expect(routes.map((r) => r.path)).toEqual(["/", "/about", "/contact"]);
});
it("should NOT include wildcard '*' routes - these cause Invalid URL TypeError", () => {
const content = `
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(2);
expect(routes.map((r) => r.path)).toEqual(["/", "/dashboard"]);
expect(routes.some((r) => r.path === "*")).toBe(false);
});
it("should NOT include '/*' wildcard routes", () => {
const content = `
<Routes>
<Route path="/" element={<Home />} />
<Route path="/*" element={<CatchAll />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/");
expect(routes.some((r) => r.path === "/*")).toBe(false);
});
it("should handle routes with single quotes", () => {
const content = `
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
<Route path='*' element={<NotFound />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(2);
expect(routes.some((r) => r.path === "*")).toBe(false);
});
it("should handle path attribute before element", () => {
// Note: The regex works when path comes before element, or when element doesn't contain >
const content = `
<Routes>
<Route path="/" element={Home} />
<Route exact path="/users" element={<Users />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(2);
expect(routes.map((r) => r.path)).toEqual(["/", "/users"]);
});
it("should not include duplicate routes", () => {
const content = `
<Routes>
<Route path="/" element={<Home />} />
<Route path="/" element={<AltHome />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(1);
});
it("should return empty array for null content", () => {
const routes = parseRoutesFromRouterFile(null);
expect(routes).toEqual([]);
});
it("should return empty array for content without routes", () => {
const content = `
export default function App() {
return <div>Hello World</div>;
}
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toEqual([]);
});
it("should include dynamic routes with params (they are valid navigation targets with placeholders)", () => {
const content = `
<Routes>
<Route path="/users/:id" element={<User />} />
</Routes>
`;
const routes = parseRoutesFromRouterFile(content);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/users/:id");
});
});
describe("parseRoutesFromNextFiles", () => {
describe("pages router", () => {
it("should parse routes from pages directory", () => {
const files = ["pages/index.tsx", "pages/about.tsx", "pages/contact.tsx"];
const routes = parseRoutesFromNextFiles(files);
expect(routes.map((r) => r.path).sort()).toEqual(
["/", "/about", "/contact"].sort(),
);
});
it("should skip API routes", () => {
const files = [
"pages/index.tsx",
"pages/api/users.ts",
"pages/api/posts.ts",
];
const routes = parseRoutesFromNextFiles(files);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/");
});
it("should skip special files", () => {
const files = [
"pages/index.tsx",
"pages/_app.tsx",
"pages/_document.tsx",
"pages/_error.tsx",
];
const routes = parseRoutesFromNextFiles(files);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/");
});
it("should skip dynamic routes", () => {
const files = [
"pages/index.tsx",
"pages/users/[id].tsx",
"pages/posts/[...slug].tsx",
];
const routes = parseRoutesFromNextFiles(files);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/");
});
it("should handle nested index files", () => {
const files = ["pages/blog/index.tsx"];
const routes = parseRoutesFromNextFiles(files);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/blog");
});
});
describe("app router", () => {
it("should parse routes from app directory", () => {
const files = [
"app/page.tsx",
"app/about/page.tsx",
"app/contact/page.tsx",
];
const routes = parseRoutesFromNextFiles(files);
expect(routes.map((r) => r.path).sort()).toEqual(
["/", "/about", "/contact"].sort(),
);
});
it("should handle src/app directory", () => {
const files = ["src/app/page.tsx", "src/app/dashboard/page.tsx"];
const routes = parseRoutesFromNextFiles(files);
expect(routes.map((r) => r.path).sort()).toEqual(
["/", "/dashboard"].sort(),
);
});
it("should skip dynamic segments in app router", () => {
const files = ["app/page.tsx", "app/users/[id]/page.tsx"];
const routes = parseRoutesFromNextFiles(files);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/");
});
it("should handle route groups (ignore parentheses)", () => {
const files = [
"app/(marketing)/about/page.tsx",
"app/(dashboard)/settings/page.tsx",
];
const routes = parseRoutesFromNextFiles(files);
expect(routes.map((r) => r.path).sort()).toEqual(
["/about", "/settings"].sort(),
);
});
});
});
......@@ -7,6 +7,127 @@ export interface ParsedRoute {
label: string;
}
/**
* Builds a human-readable label from a route path.
*/
export function buildRouteLabel(path: string): string {
return path === "/"
? "Home"
: path
.split("/")
.filter((segment) => segment && !segment.startsWith(":"))
.pop()
?.replace(/[-_]/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()) || path;
}
/**
* Parses routes from a React Router file content (e.g., App.tsx).
* Extracts route paths from <Route path="..." /> elements.
*/
export function parseRoutesFromRouterFile(
content: string | null,
): ParsedRoute[] {
if (!content) {
return [];
}
try {
const parsedRoutes: ParsedRoute[] = [];
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = routePathsRegex.exec(content)) !== null) {
const path = match[1];
// Skip wildcard/catch-all routes like "*" - they are not valid navigation targets
// and cause 'Invalid URL' TypeError when clicked
if (path === "*" || path === "/*") continue;
const label = buildRouteLabel(path);
if (!parsedRoutes.some((r) => r.path === path)) {
parsedRoutes.push({ path, label });
}
}
return parsedRoutes;
} catch (e) {
console.error("Error parsing router file:", e);
return [];
}
}
/**
* Parses routes from Next.js file-based routing (pages/ or app/ directories).
*/
export function parseRoutesFromNextFiles(files: string[]): ParsedRoute[] {
const nextRoutes = new Set<string>();
// pages directory (pages router)
const pageFileRegex = /^(?:pages)\/(.+)\.(?:js|jsx|ts|tsx|mdx)$/i;
for (const file of files) {
if (!file.startsWith("pages/")) continue;
if (file.startsWith("pages/api/")) continue; // skip api routes
const baseName = file.split("/").pop() || "";
if (baseName.startsWith("_")) continue; // _app, _document, etc.
const m = file.match(pageFileRegex);
if (!m) continue;
let routePath = m[1];
// Ignore dynamic routes containing [ ]
if (routePath.includes("[")) continue;
// Normalize index files
if (routePath === "index") {
nextRoutes.add("/");
continue;
}
if (routePath.endsWith("/index")) {
routePath = routePath.slice(0, -"/index".length);
}
nextRoutes.add("/" + routePath);
}
// app directory (app router)
const appPageRegex = /^(?:src\/)?app\/(.*)\/page\.(?:js|jsx|ts|tsx|mdx)$/i;
for (const file of files) {
const lower = file.toLowerCase();
if (
lower === "app/page.tsx" ||
lower === "app/page.jsx" ||
lower === "app/page.js" ||
lower === "app/page.mdx" ||
lower === "app/page.ts" ||
lower === "src/app/page.tsx" ||
lower === "src/app/page.jsx" ||
lower === "src/app/page.js" ||
lower === "src/app/page.mdx" ||
lower === "src/app/page.ts"
) {
nextRoutes.add("/");
continue;
}
const m = file.match(appPageRegex);
if (!m) continue;
const routeSeg = m[1];
// Ignore dynamic segments and grouping folders like (marketing)
if (routeSeg.includes("[")) continue;
const cleaned = routeSeg
.split("/")
.filter((s) => s && !s.startsWith("("))
.join("/");
if (!cleaned) {
nextRoutes.add("/");
} else {
nextRoutes.add("/" + cleaned);
}
}
return Array.from(nextRoutes).map((path) => ({
path,
label: buildRouteLabel(path),
}));
}
/**
* Loads the app router file and parses available routes for quick navigation.
*/
......@@ -37,118 +158,10 @@ export function useParseRouter(appId: number | null) {
// Parse routes either from Next.js file-based routing or from router file
useEffect(() => {
const buildLabel = (path: string) =>
path === "/"
? "Home"
: path
.split("/")
.filter((segment) => segment && !segment.startsWith(":"))
.pop()
?.replace(/[-_]/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()) || path;
const setFromNextFiles = (files: string[]) => {
const nextRoutes = new Set<string>();
// pages directory (pages router)
const pageFileRegex = /^(?:pages)\/(.+)\.(?:js|jsx|ts|tsx|mdx)$/i;
for (const file of files) {
if (!file.startsWith("pages/")) continue;
if (file.startsWith("pages/api/")) continue; // skip api routes
const baseName = file.split("/").pop() || "";
if (baseName.startsWith("_")) continue; // _app, _document, etc.
const m = file.match(pageFileRegex);
if (!m) continue;
let routePath = m[1];
// Ignore dynamic routes containing [ ]
if (routePath.includes("[")) continue;
// Normalize index files
if (routePath === "index") {
nextRoutes.add("/");
continue;
}
if (routePath.endsWith("/index")) {
routePath = routePath.slice(0, -"/index".length);
}
nextRoutes.add("/" + routePath);
}
// app directory (app router)
const appPageRegex =
/^(?:src\/)?app\/(.*)\/page\.(?:js|jsx|ts|tsx|mdx)$/i;
for (const file of files) {
const lower = file.toLowerCase();
if (
lower === "app/page.tsx" ||
lower === "app/page.jsx" ||
lower === "app/page.js" ||
lower === "app/page.mdx" ||
lower === "app/page.ts" ||
lower === "src/app/page.tsx" ||
lower === "src/app/page.jsx" ||
lower === "src/app/page.js" ||
lower === "src/app/page.mdx" ||
lower === "src/app/page.ts"
) {
nextRoutes.add("/");
continue;
}
const m = file.match(appPageRegex);
if (!m) continue;
const routeSeg = m[1];
// Ignore dynamic segments and grouping folders like (marketing)
if (routeSeg.includes("[")) continue;
const cleaned = routeSeg
.split("/")
.filter((s) => s && !s.startsWith("("))
.join("/");
if (!cleaned) {
nextRoutes.add("/");
} else {
nextRoutes.add("/" + cleaned);
}
}
const parsed = Array.from(nextRoutes).map((path) => ({
path,
label: buildLabel(path),
}));
setRoutes(parsed);
};
const setFromRouterFile = (content: string | null) => {
if (!content) {
setRoutes([]);
return;
}
try {
const parsedRoutes: ParsedRoute[] = [];
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = routePathsRegex.exec(content)) !== null) {
const path = match[1];
const label = buildLabel(path);
if (!parsedRoutes.some((r) => r.path === path)) {
parsedRoutes.push({ path, label });
}
}
setRoutes(parsedRoutes);
} catch (e) {
console.error("Error parsing router file:", e);
setRoutes([]);
}
};
if (isNextApp && app?.files) {
setFromNextFiles(app.files);
setRoutes(parseRoutesFromNextFiles(app.files));
} else {
setFromRouterFile(routerContent ?? null);
setRoutes(parseRoutesFromRouterFile(routerContent ?? null));
}
}, [isNextApp, app?.files, routerContent]);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论