From 5d9bfa39780abd44bfe926912cce6a913347aed4 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 6 Jul 2026 10:05:57 +0200 Subject: [PATCH] fix(boards): open tabs before async fetch in bulk action Pre-open blank tabs synchronously to preserve browser user-activation, then navigate those tab handles after app URLs are fetched. Also close surplus tabs and update tests for the new flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/board/open-apps-in-new-tabs.ts | 16 ++++++++++++--- .../board/test/open-apps-in-new-tabs.spec.ts | 20 +++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts b/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts index 9e0614607..0a59bbace 100644 --- a/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts +++ b/apps/nextjs/src/components/board/open-apps-in-new-tabs.ts @@ -19,13 +19,23 @@ export const openAppsInNewTabs = async ( if (appIds.length === 0) return; const distinctAppIds = [...new Set(appIds)]; + const openedWindows = distinctAppIds.map(() => window.open("", "_blank", "noopener,noreferrer")); + for (const openedWindow of openedWindows) { + if (!openedWindow) continue; + openedWindow.opener = null; + } + const apps = await fetchAppsByIds(distinctAppIds); const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); + const tabsToClose = openedWindows.slice(appsWithUrls.length).filter((openedWindow): openedWindow is Window => Boolean(openedWindow)); + for (const tabToClose of tabsToClose) { + tabToClose.close(); + } - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined, "_blank", "noopener,noreferrer"); + for (const [index, app] of appsWithUrls.entries()) { + const openedWindow = openedWindows[index]; if (openedWindow) { - openedWindow.opener = null; + openedWindow.location.href = app.href; continue; } diff --git a/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts b/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts index 989f85abf..88ae08913 100644 --- a/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts +++ b/apps/nextjs/src/components/board/test/open-apps-in-new-tabs.spec.ts @@ -3,8 +3,11 @@ import { describe, expect, test, vi } from "vitest"; import { openAppsInNewTabs } from "../open-apps-in-new-tabs"; describe("openAppsInNewTabs", () => { - test("opens fetched apps with URLs in new tabs with noopener", async () => { - const open = vi.spyOn(window, "open").mockReturnValue({ opener: null } as unknown as Window); + test("opens tabs synchronously and navigates them to fetched URLs", async () => { + const openedWindowOne = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window; + const openedWindowTwo = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window; + const openedWindowThree = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValueOnce(openedWindowOne).mockReturnValueOnce(openedWindowTwo).mockReturnValueOnce(openedWindowThree); const fetchAppsByIds = vi .fn() .mockResolvedValue([{ href: "https://example.com/one" }, { href: null }, { href: "https://example.com/two" }]); @@ -17,14 +20,19 @@ describe("openAppsInNewTabs", () => { }); expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one", "app-two", "app-three"]); - expect(open).toHaveBeenCalledTimes(2); - expect(open).toHaveBeenNthCalledWith(1, "https://example.com/one", "_blank", "noopener,noreferrer"); - expect(open).toHaveBeenNthCalledWith(2, "https://example.com/two", "_blank", "noopener,noreferrer"); + expect(open).toHaveBeenCalledTimes(3); + expect(open).toHaveBeenNthCalledWith(1, "", "_blank", "noopener,noreferrer"); + expect(open).toHaveBeenNthCalledWith(2, "", "_blank", "noopener,noreferrer"); + expect(open).toHaveBeenNthCalledWith(3, "", "_blank", "noopener,noreferrer"); + expect(openedWindowOne.location.href).toBe("https://example.com/one"); + expect(openedWindowTwo.location.href).toBe("https://example.com/two"); + expect(openedWindowThree.close).toHaveBeenCalledTimes(1); expect(openConfirmModal).not.toHaveBeenCalled(); }); test("de-duplicates app IDs before fetching", async () => { - const open = vi.spyOn(window, "open").mockReturnValue({ opener: null } as unknown as Window); + const openedWindow = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValue(openedWindow); const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]); const openConfirmModal = vi.fn();