authorgravatar for snorteman@gmail.comScott Norteman <snorteman@gmail.com> 2026-10-12 21:29:35-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 22:29:35-03:00
logdf26ef99859ac757409f5332b185a89cc7f40267
treec110820d985ecc0b9c643c31cff16cb49f816fab
parent41d86c1b1c273ed662d59c90aa9685476bac962c
signature Signed by PGP key B5690EEEBB952194

fix(web): a numeric q in the pull requests URL no longer crashes the page (#18666)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 44 insertions(+), 2 deletions(-)

apps/web/src/components/pullRequest/pullRequestListPreferences.ts+1-1
...@@ -25,7 +25,7 @@ export interface PullRequestListPreferences {...@@ -25,7 +25,7 @@ export interface PullRequestListPreferences {
25 readonly environmentId?: EnvironmentId;25 readonly environmentId?: EnvironmentId;
26 readonly projectId?: ProjectId;26 readonly projectId?: ProjectId;
27 readonly host?: string;27 readonly host?: string;
28 readonly q?: string;28 readonly q?: string | undefined;
29 readonly draft?: "only" | "hide";29 readonly draft?: "only" | "hide";
30 readonly review?: NonNullable<PullRequestListFilters["review"]>;30 readonly review?: NonNullable<PullRequestListFilters["review"]>;
31 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;31 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts created+40
...@@ -0,0 +1,40 @@
1import {
2 createMemoryHistory,
3 createRootRoute,
4 createRoute,
5 createRouter,
6} from "@tanstack/react-router";
7import { describe, expect, it } from "vite-plus/test";
8
9import { Route as PullRequestsRoute } from "../../routes/_chat.pull-requests";
10
11/** Matches a URL in a router shaped like the app's: a layout without its own search rules. */
12function matchedSearch(url: string) {
13 const root = createRootRoute();
14 const chat = createRoute({ getParentRoute: () => root, id: "_chat" });
15 const pullRequests = createRoute({
16 getParentRoute: () => chat,
17 path: "pull-requests",
18 validateSearch: PullRequestsRoute.options.validateSearch,
19 });
20 const router = createRouter({
21 routeTree: root.addChildren([chat.addChildren([pullRequests])]),
22 history: createMemoryHistory({ initialEntries: [url] }),
23 });
24 // The search a component reads through `useSearch` is the matched route's, not the URL's.
25 return router.matchRoutes(router.latestLocation).at(-1)?.search as
26 | Record<string, unknown>
27 | undefined;
28}
29
30describe("pull requests route search", () => {
31 it("drops a q the URL parser turned into a number", () => {
32 expect(matchedSearch("/pull-requests?q=4089")?.q).toBeUndefined();
33 expect(matchedSearch("/pull-requests?q=true")?.q).toBeUndefined();
34 });
35
36 it("keeps a q that is text, including quoted digits", () => {
37 expect(matchedSearch("/pull-requests?q=fix")?.q).toBe("fix");
38 expect(matchedSearch("/pull-requests?q=%224089%22")?.q).toBe("4089");
39 });
40});
apps/web/src/routes/_chat.pull-requests.tsx+3-1
...@@ -332,7 +332,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -332,7 +332,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({
332 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId332 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId
333 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }333 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }
334 : {}),334 : {}),
335 ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}),335 // Always an own key: the router lays this result over the parent route's unvalidated
336 // search, so leaving out a `q` the URL parser made a number would let the number through.
337 q: typeof raw.q === "string" && raw.q ? raw.q.slice(0, 200) : undefined,
336 ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}),338 ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}),
337 ...(raw.review === "approved" ||339 ...(raw.review === "approved" ||
338 raw.review === "changes-requested" ||340 raw.review === "changes-requested" ||