From 6c2c3b9c24bf9ce6bc6de205fb074792f08d74be Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Tue, 21 Jul 2026 13:08:37 -0700 Subject: [PATCH] fix: guard against infinite loops (#10281) --- packages/insomnia/src/common/project.test.ts | 34 +++++++++++++++++-- packages/insomnia/src/common/project.ts | 14 ++++++-- .../project-navigation-sidebar-utils.ts | 10 ++---- .../project-navigation-sidebar.tsx | 24 ++++++++----- .../ui/utils/dedupe-collection-items.test.ts | 20 +++++++++++ .../src/ui/utils/dedupe-collection-items.ts | 13 +++++++ 6 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 packages/insomnia/src/ui/utils/dedupe-collection-items.test.ts create mode 100644 packages/insomnia/src/ui/utils/dedupe-collection-items.ts diff --git a/packages/insomnia/src/common/project.test.ts b/packages/insomnia/src/common/project.test.ts index 050179d698..ffe0e41f06 100644 --- a/packages/insomnia/src/common/project.test.ts +++ b/packages/insomnia/src/common/project.test.ts @@ -1,9 +1,39 @@ -import { services } from 'insomnia-data'; +import { services, type Workspace } from 'insomnia-data'; import { beforeEach, describe, expect, it } from 'vitest'; import { database as db } from '~/common/database'; -import { checkAllProjectSyncStatus } from './project'; +import { checkAllProjectSyncStatus, getUnsyncedRemoteWorkspaces, type InsomniaFile } from './project'; + +const mkRemoteFile = (id: string): InsomniaFile => ({ + id, + name: `${id}-name`, + scope: 'unsynced', + label: 'Unsynced', + created: 0, + lastModifiedTimestamp: 0, +}); + +const mkWorkspace = (id: string): Workspace => ({ _id: id, scope: 'collection' }) as unknown as Workspace; + +describe('getUnsyncedRemoteWorkspaces', () => { + it('excludes a remote file once its workspace exists locally', () => { + const remoteFiles = [mkRemoteFile('wrk_downloaded'), mkRemoteFile('wrk_pending')]; + const workspaces = [mkWorkspace('wrk_downloaded')]; + + const result = getUnsyncedRemoteWorkspaces(remoteFiles, workspaces); + + expect(result.map(f => f.id)).toEqual(['wrk_pending']); + }); + + it('de-duplicates remote files sharing an id', () => { + const remoteFiles = [mkRemoteFile('wrk_dup'), mkRemoteFile('wrk_dup')]; + + const result = getUnsyncedRemoteWorkspaces(remoteFiles, []); + + expect(result.map(f => f.id)).toEqual(['wrk_dup']); + }); +}); describe('checkAllProjectSyncStatus', () => { beforeEach(async () => { diff --git a/packages/insomnia/src/common/project.ts b/packages/insomnia/src/common/project.ts index e5ea4fa92f..c2ed3bf6e7 100644 --- a/packages/insomnia/src/common/project.ts +++ b/packages/insomnia/src/common/project.ts @@ -209,8 +209,18 @@ export async function getAllLocalFiles({ projectId }: { projectId: string }) { return files; } -export const getUnsyncedRemoteWorkspaces = (remoteFiles: InsomniaFile[], workspaces: Workspace[]) => - remoteFiles.filter(remoteFile => !workspaces.find(w => w._id === remoteFile.id)); +export const getUnsyncedRemoteWorkspaces = (remoteFiles: InsomniaFile[], workspaces: Workspace[]) => { + const seenIds = new Set(); + const uniqueRemoteFiles = remoteFiles.filter(file => { + if (seenIds.has(file.id)) { + return false; + } + seenIds.add(file.id); + return true; + }); + + return uniqueRemoteFiles.filter(remoteFile => !workspaces.some(w => w._id === remoteFile.id)); +}; /** * Get all projects for an organization with their associated git repositories diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts index 19db436420..3d07dd5881 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts @@ -18,6 +18,7 @@ import { fuzzyMatchAll } from 'insomnia-data/common'; import { database } from '~/common/database'; import { sortMethodMap } from '~/common/sorting'; import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; +import { dedupeCollectionItems } from '~/ui/utils/dedupe-collection-items'; export interface SlimRequestDoc extends BaseModel { type: 'Request' | 'GrpcRequest' | 'WebSocketRequest' | 'SocketIORequest' | 'RequestGroup'; @@ -211,14 +212,7 @@ export function flattenCollectionChildren( const { isRequestGroup } = models.requestGroup; const collection: Child[] = []; - const seenIds = new Set(); - const uniqueRequests = allRequests.filter(doc => { - if (seenIds.has(doc._id)) { - return false; - } - seenIds.add(doc._id); - return true; - }); + const uniqueRequests = dedupeCollectionItems(allRequests, doc => doc._id); // map of parentId to its direct children requests and request groups const requestsByParentId = new Map(); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index f30f8fb61a..187306efc9 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -57,6 +57,7 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; +import { dedupeCollectionItems } from '~/ui/utils/dedupe-collection-items'; import { getAllRemoteBackendProjectsOfOrg } from '~/ui/utils/remote-projects'; import { Icon } from '../../icon'; @@ -75,6 +76,9 @@ import { useProjectNavigationSidebarNavigation } from './use-project-navigation- import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop'; import { WorkspaceNode } from './workspace-node'; +const getSidebarGridListItemId = (item: FlatItem): string => + `${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`; + interface ProjectNavigationSidebarProps { storageRules: StorageRules; activeNodeId?: string; @@ -354,10 +358,11 @@ const ProjectNavigationSidebarInner = ( for (const file of files) { const projectId = remoteIdToProjectIdMap.get(file.teamProjectId); if (projectId) { - if (!filesByProjectId.has(projectId)) { - filesByProjectId.set(projectId, []); + const projectFiles = filesByProjectId.get(projectId) ?? []; + if (projectFiles.some(f => f.id === file.rootDocumentId)) { + continue; } - filesByProjectId.get(projectId)?.push({ + projectFiles.push({ id: file.rootDocumentId, name: file.name, scope: 'unsynced', @@ -366,6 +371,7 @@ const ProjectNavigationSidebarInner = ( created: 0, lastModifiedTimestamp: 0, }); + filesByProjectId.set(projectId, projectFiles); } } @@ -996,13 +1002,16 @@ const ProjectNavigationSidebarInner = ( const [isShortcutCreateOpen, setIsShortcutCreateOpen] = useState(false); // The item that the shortcut create dropdown is targeting by keyboard up and down arrow keys const [shortcutTargetItemId, setShortcutTargetItemId] = useState(null); - const visibleFlatItems = useMemo(() => flatItems.filter(i => !i.hidden), [flatItems]); + const visibleFlatItems = useMemo( + () => dedupeCollectionItems(flatItems.filter(i => !i.hidden), getSidebarGridListItemId), + [flatItems], + ); const virtualizer = useVirtualizer({ getScrollElement: () => parentRef.current, count: visibleFlatItems.length, estimateSize: useCallback(() => 32, []), overscan: 30, - getItemKey: index => visibleFlatItems[index].doc._id, + getItemKey: index => getSidebarGridListItemId(visibleFlatItems[index]), }); const sidebarDragAndDropHooks = useSidebarDragAndDrop({ flatItems, @@ -1245,9 +1254,8 @@ const ProjectNavigationSidebarInner = ( return ( { if (e.button === 1 && item.kind === 'collectionChild') { diff --git a/packages/insomnia/src/ui/utils/dedupe-collection-items.test.ts b/packages/insomnia/src/ui/utils/dedupe-collection-items.test.ts new file mode 100644 index 0000000000..c61292022d --- /dev/null +++ b/packages/insomnia/src/ui/utils/dedupe-collection-items.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { dedupeCollectionItems } from './dedupe-collection-items'; + +describe('dedupeCollectionItems', () => { + it('keeps the first occurrence of each id and preserves order', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'a' }, { id: 'c' }]; + + const result = dedupeCollectionItems(items, item => item.id); + + expect(result.map(i => i.id)).toEqual(['a', 'b', 'c']); + expect(result[0]).toBe(items[0]); + }); + + it('returns the same array reference when there are no duplicates', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + + expect(dedupeCollectionItems(items, item => item.id)).toBe(items); + }); +}); diff --git a/packages/insomnia/src/ui/utils/dedupe-collection-items.ts b/packages/insomnia/src/ui/utils/dedupe-collection-items.ts new file mode 100644 index 0000000000..9271f8f4f0 --- /dev/null +++ b/packages/insomnia/src/ui/utils/dedupe-collection-items.ts @@ -0,0 +1,13 @@ +export function dedupeCollectionItems(items: T[], getId: (item: T) => string): T[] { + const seenIds = new Set(); + const uniqueItems = items.filter(item => { + const id = getId(item); + if (seenIds.has(id)) { + return false; + } + seenIds.add(id); + return true; + }); + + return uniqueItems.length === items.length ? items : uniqueItems; +}