From 81c9e9e5305beb1f406c11e9f1b99279b4c15216 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:03:23 +0000 Subject: [PATCH 1/4] fix(webapp): keep the branches list query string when archiving a branch Archiving a branch redirected to a rebuilt branches path with no query string, so the list reset to page 1 and any search or filter was lost. The archive dialog already submits the page it was opened from as a hidden redirectPath field, and the failure path already used it, so use it for the success redirect too (sanitized to stay same-origin). Co-Authored-By: Claude --- .../app/routes/resources.branches.archive.tsx | 13 ++-- .../webapp/test/archiveBranchRedirect.test.ts | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/test/archiveBranchRedirect.test.ts diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index bf9e05a0d64..d14d5a7a217 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -13,7 +13,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { requireUserId } from "~/services/session.server"; -import { branchesDevPath, branchesPath } from "~/utils/pathBuilder"; +import { sanitizeRedirectPath } from "~/utils"; const ArchiveBranchOptions = z.object({ environmentId: z.string(), @@ -35,6 +35,9 @@ export async function action({ request }: ActionFunctionArgs) { return redirectWithErrorMessage("/", request, "Invalid form data"); } + // Keep the post-action redirect same-origin. + const redirectPath = sanitizeRedirectPath(submission.value.redirectPath); + const archiveBranchService = new ArchiveBranchService(); const result = await archiveBranchService.call( @@ -45,16 +48,16 @@ export async function action({ request }: ActionFunctionArgs) { ); if (result.success) { + // Back to the exact list page the archive was started from, so pagination, + // filters and search survive. return redirectWithSuccessMessage( - result.branch.type === "DEVELOPMENT" - ? branchesDevPath(result.organization, result.project, result.branch) - : branchesPath(result.organization, result.project, result.branch), + redirectPath, request, `Branch "${result.branch.branchName}" archived` ); } - return redirectWithErrorMessage(submission.value.redirectPath, request, result.error); + return redirectWithErrorMessage(redirectPath, request, result.error); } export function ArchiveButton({ diff --git a/apps/webapp/test/archiveBranchRedirect.test.ts b/apps/webapp/test/archiveBranchRedirect.test.ts new file mode 100644 index 00000000000..5a7677c97b4 --- /dev/null +++ b/apps/webapp/test/archiveBranchRedirect.test.ts @@ -0,0 +1,59 @@ +// The archive dialog submits the page it was opened from, so archiving from a +// paginated or filtered branches list must land back on that exact page instead +// of a bare branches path that resets the list to page 1. + +import { describe, expect, it, vi } from "vitest"; +import { action } from "~/routes/resources.branches.archive"; + +vi.mock("~/services/session.server", () => ({ + requireUserId: vi.fn().mockResolvedValue("user_1"), +})); + +const archiveSucceeds = { value: true }; + +vi.mock("~/services/archiveBranch.server", () => ({ + ArchiveBranchService: class { + async call() { + return archiveSucceeds.value + ? { success: true as const, branch: { branchName: "feat/checkout" } } + : { success: false as const, error: "Failed to archive branch" }; + } + }, +})); + +const LIST_PATH = "/orgs/o/projects/p/env/preview/branches?page=3&search=feat"; + +async function archive(redirectPath: string) { + const body = new URLSearchParams({ environmentId: "env_1", redirectPath }); + + return (await (action as any)({ + request: new Request("https://app.example.com/resources/branches/archive", { + method: "POST", + body, + }), + params: {}, + context: {}, + })) as Response; +} + +describe("archiving a branch returns to the page it was started from", () => { + it("preserves the query string on success", async () => { + const response = await archive(LIST_PATH); + + expect(response.headers.get("Location")).toBe(LIST_PATH); + }); + + it("preserves the query string on failure", async () => { + archiveSucceeds.value = false; + const response = await archive(LIST_PATH); + archiveSucceeds.value = true; + + expect(response.headers.get("Location")).toBe(LIST_PATH); + }); + + it("keeps the redirect same-origin", async () => { + const response = await archive("//evil.example.com/branches"); + + expect(response.headers.get("Location")).toBe("/"); + }); +}); From 5669e0d23eaa7476cf8de57f5c8fa44bfc8b0a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:06:16 +0000 Subject: [PATCH 2/4] docs: add server change note for the branches list archive redirect Co-Authored-By: Claude --- .server-changes/archive-branch-keeps-list-page.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/archive-branch-keeps-list-page.md diff --git a/.server-changes/archive-branch-keeps-list-page.md b/.server-changes/archive-branch-keeps-list-page.md new file mode 100644 index 00000000000..5b5c423d867 --- /dev/null +++ b/.server-changes/archive-branch-keeps-list-page.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Archiving a branch now returns you to the same page of the branches list, keeping your place, search and filters instead of resetting to the first page. From 9c1842231c83473bad9a1288b7d4c6c656b0d749 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:10:13 +0000 Subject: [PATCH 3/4] refactor(webapp): drop the comments from the branch archive redirect Co-Authored-By: Claude --- apps/webapp/app/routes/resources.branches.archive.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index d14d5a7a217..3fb1d5c64b1 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -35,7 +35,6 @@ export async function action({ request }: ActionFunctionArgs) { return redirectWithErrorMessage("/", request, "Invalid form data"); } - // Keep the post-action redirect same-origin. const redirectPath = sanitizeRedirectPath(submission.value.redirectPath); const archiveBranchService = new ArchiveBranchService(); @@ -48,8 +47,6 @@ export async function action({ request }: ActionFunctionArgs) { ); if (result.success) { - // Back to the exact list page the archive was started from, so pagination, - // filters and search survive. return redirectWithSuccessMessage( redirectPath, request, From ec7a892e1c341ec80f121a6dcce7993315954105 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:03:58 +0000 Subject: [PATCH 4/4] test(webapp): reset the archive stub flag in beforeEach The failure case toggled a module-level flag and reset it inline, so any early exit between the toggle and the reset would leave the stub in its failure state for the rest of the file. Reset it in beforeEach instead, matching dashboardAgentClientMetadata.test.ts and metadataRouteReplicaLag.guard.test.ts. Co-Authored-By: Claude --- apps/webapp/test/archiveBranchRedirect.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/webapp/test/archiveBranchRedirect.test.ts b/apps/webapp/test/archiveBranchRedirect.test.ts index 5a7677c97b4..f096376e7d5 100644 --- a/apps/webapp/test/archiveBranchRedirect.test.ts +++ b/apps/webapp/test/archiveBranchRedirect.test.ts @@ -2,7 +2,7 @@ // paginated or filtered branches list must land back on that exact page instead // of a bare branches path that resets the list to page 1. -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { action } from "~/routes/resources.branches.archive"; vi.mock("~/services/session.server", () => ({ @@ -37,6 +37,10 @@ async function archive(redirectPath: string) { } describe("archiving a branch returns to the page it was started from", () => { + beforeEach(() => { + archiveSucceeds.value = true; + }); + it("preserves the query string on success", async () => { const response = await archive(LIST_PATH); @@ -45,8 +49,8 @@ describe("archiving a branch returns to the page it was started from", () => { it("preserves the query string on failure", async () => { archiveSucceeds.value = false; + const response = await archive(LIST_PATH); - archiveSucceeds.value = true; expect(response.headers.get("Location")).toBe(LIST_PATH); });