refactor(landing): replace untracked-projects fetcher with a DB query

The untracked-projects route was only ever loaded through a fetcher on mount
(organization layout and the import/export settings), and refreshed as a
side effect of every action's revalidation.

- Add `untrackedProjectsQuery` keyed by the tracked organization ids, derived
  reactively from `useOrganizations()` instead of reading localStorage.
- Invalidate it from `updateAppDataOnDbChanges` on project / workspace changes.
- Count workspaces per project in parallel instead of serially.
- Remove the `untracked-projects` route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Curry YangandClaude Opus 5.5 authored and GitHub committed 2026-11-03 16:25:20 +08:00
1 parent e2eb19f135
commit cdb83a73b7
6 files changed
+123 -85

No files matched your search

@@ -3,7 +3,7 @@ import type * as InsomniaData from 'insomnia-data';
import type { BaseModel, ChangeBufferEvent, CollectionWorkspaceChildren, OrganizationData } from 'insomnia-data';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { organizationDataKeys, workspaceChildrenKeys } from '~/common/app-data';
import { organizationDataKeys, untrackedProjectsKeys, workspaceChildrenKeys } from '~/common/app-data';
vi.mock('insomnia-data', async () => {
const actual = await vi.importActual<typeof InsomniaData>('insomnia-data');
@@ -13,13 +13,15 @@ vi.mock('insomnia-data', async () => {
appData: {
getWorkspaceChildren: vi.fn(),
},
project: { list: vi.fn() },
workspace: { list: vi.fn(), count: vi.fn() },
},
};
});
import { services } from 'insomnia-data';
import { prefetchUncachedWorkspaceChildren, updateAppDataOnDbChanges } from '../app-data';
import { prefetchUncachedWorkspaceChildren, untrackedProjectsQuery, updateAppDataOnDbChanges } from '../app-data';
function fakeDoc(type: BaseModel['type'], overrides: Partial<BaseModel> = {}): BaseModel {
return {
@@ -200,6 +202,22 @@ describe('updateAppDataOnDbChanges', () => {
expect(queryClient.getQueryState(workspaceChildrenKeys.byWorkspaceId(workspaceId))?.isInvalidated).toBe(true);
});
it('invalidates untracked projects on project and workspace changes, but not on workspace meta changes', () => {
const key = untrackedProjectsKeys.byTrackedOrganizationIds(['org_1']);
const isInvalidated = () => queryClient.getQueryState(key)?.isInvalidated;
queryClient.setQueryData(key, { untrackedProjects: [], untrackedWorkspaces: [] });
updateAppDataOnDbChanges(queryClient, [change('update', fakeDoc('WorkspaceMeta', { parentId: 'wrk_1' }))]);
expect(isInvalidated()).toBe(false);
updateAppDataOnDbChanges(queryClient, [change('insert', fakeDoc('Project', { parentId: 'org_gone' }))]);
expect(isInvalidated()).toBe(true);
queryClient.setQueryData(key, { untrackedProjects: [], untrackedWorkspaces: [] });
updateAppDataOnDbChanges(queryClient, [change('remove', fakeDoc('Workspace', { parentId: 'proj_1' }))]);
expect(isInvalidated()).toBe(true);
});
it('ignores doc types it does not monitor', () => {
const settings = fakeDoc('Settings', { _id: 'settings_1' });
expect(() => updateAppDataOnDbChanges(queryClient, [change('update', settings)])).not.toThrow();
@@ -270,3 +288,28 @@ describe('prefetchUncachedWorkspaceChildren', () => {
expect(services.appData.getWorkspaceChildren).not.toHaveBeenCalled();
});
});
describe('untrackedProjectsQuery', () => {
it('lists projects outside the tracked organizations with their workspace counts, plus orphaned workspaces', async () => {
const orphanWorkspace = fakeDoc('Workspace', { _id: 'wrk_orphan', parentId: null as unknown as string });
vi.mocked(services.project.list).mockResolvedValue([
fakeDoc('Project', { _id: 'proj_a' }),
fakeDoc('Project', { _id: 'proj_b' }),
] as any);
vi.mocked(services.workspace.list).mockResolvedValue([orphanWorkspace] as any);
vi.mocked(services.workspace.count).mockImplementation(async query =>
(query as { parentId: string }).parentId === 'proj_a' ? 2 : 0,
);
const queryClient = new QueryClient();
const result = await queryClient.fetchQuery(untrackedProjectsQuery(['org_1', 'org_2']));
expect(services.project.list).toHaveBeenCalledWith({ parentId: { $nin: ['org_1', 'org_2'] } });
expect(services.workspace.list).toHaveBeenCalledWith({ parentId: null });
expect(result.untrackedProjects.map(p => [p._id, p.workspacesCount])).toEqual([
['proj_a', 2],
['proj_b', 0],
]);
expect(result.untrackedWorkspaces).toEqual([orphanWorkspace]);
});
});
+41
View File
@@ -4,6 +4,8 @@ import type {
ChangeBufferEvent,
CollectionWorkspaceChildren,
OrganizationData,
Project,
Workspace,
WorkspaceChildren,
WorkspaceChildrenForScope,
WorkspaceMeta,
@@ -40,6 +42,39 @@ export const workspaceChildrenQuery = <S extends WorkspaceScope | undefined = un
queryFn: async () => (await services.appData.getWorkspaceChildren([workspaceId], scope)).get(workspaceId),
});
export const untrackedProjectsKeys = {
all: ['untracked-projects'],
byTrackedOrganizationIds: (trackedOrganizationIds: string[]) => [
...untrackedProjectsKeys.all,
...trackedOrganizationIds,
],
};
export interface UntrackedProjectsData {
untrackedProjects: (Project & { workspacesCount: number })[];
untrackedWorkspaces: Workspace[];
}
// Projects whose parent is none of the tracked organizations, and workspaces with no project.
// Invalidated by `updateAppDataOnDbChanges` on any project / workspace change.
export const untrackedProjectsQuery = (trackedOrganizationIds: string[]) =>
queryOptions({
queryKey: untrackedProjectsKeys.byTrackedOrganizationIds(trackedOrganizationIds),
queryFn: async (): Promise<UntrackedProjectsData> => {
const [projects, untrackedWorkspaces] = await Promise.all([
services.project.list({ parentId: { $nin: trackedOrganizationIds } }),
services.workspace.list({ parentId: null }),
]);
const untrackedProjects = await Promise.all(
projects.map(async project => ({
...project,
workspacesCount: await services.workspace.count({ parentId: project._id }),
})),
);
return { untrackedProjects, untrackedWorkspaces };
},
});
const COLLECTION_REQUEST_DOC_TYPES: string[] = [
models.request.type,
models.grpcRequest.type,
@@ -258,6 +293,7 @@ function updateCollectionChildrenWithUpdatedDoc(
export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: ChangeBufferEvent[]) {
const organizationIdsToRevalidate = new Set<string>();
const workspaceIdsToRevalidate = new Set<string>();
let shouldRevalidateUntrackedProjects = false;
// We do not use the patches here because some db operations do not contain patches like git repo file watcher
for (const [event, doc] of changes) {
@@ -266,6 +302,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan
}
if (doc.type === models.project.type) {
shouldRevalidateUntrackedProjects = true;
organizationIdsToRevalidate.add(doc.parentId);
// The project may have moved from a different, already-cached organization (e.g. the Konnect
// migration re-parents projects across organizations) — find and revalidate its old one too,
@@ -281,6 +318,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan
}
if (doc.type === models.workspace.type) {
shouldRevalidateUntrackedProjects = true;
const { organizationId } = findOrganizationAndProjectIdForWorkspace(queryClient, doc) || {};
if (organizationId) {
organizationIdsToRevalidate.add(organizationId);
@@ -393,4 +431,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan
refetchType: 'all',
}),
);
if (shouldRevalidateUntrackedProjects) {
queryClient.invalidateQueries({ queryKey: untrackedProjectsKeys.all });
}
}
+2 -11
View File
@@ -10,7 +10,6 @@ import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organizati
import { detectKonnectOrgMigration } from '~/konnect/migrate-konnect-organization';
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects';
import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects';
import { AnalyticsEvent } from '~/ui/analytics';
import { useEditorHistoryTabCleanup } from '~/ui/components/.client/codemirror/editor-history-cleanup';
import { CommandPalette } from '~/ui/components/command-palette';
@@ -35,6 +34,7 @@ import { RunnerProvider } from '~/ui/context/app/runner-context';
import { useCurrentPlan, useCurrentUser, useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useCloseConnection } from '~/ui/hooks/use-close-connection';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import { useUntrackedProjects } from '~/ui/hooks/use-untracked-projects';
import { refreshKonnectAccess } from '~/ui/organization-utils';
import type { AsyncTask } from '~/ui/utils/router';
@@ -185,7 +185,6 @@ const Component = () => {
const navigate = useNavigate();
const isScratchpadWorkspace =
workspaceData?.activeWorkspace && models.workspace.isScratchpad(workspaceData.activeWorkspace);
const untrackedProjectsFetcher = useUntrackedProjectsLoaderFetcher();
const { organizationId, projectId } = useParams() as {
organizationId: string;
projectId?: string;
@@ -219,13 +218,6 @@ const Component = () => {
}
}, [organizationId, asyncTaskList, syncOrgsAndProjects]);
useEffect(() => {
const isIdleAndUninitialized = untrackedProjectsFetcher.state === 'idle' && !untrackedProjectsFetcher.data;
if (isIdleAndUninitialized) {
untrackedProjectsFetcher.load();
}
}, [organizationId, untrackedProjectsFetcher]);
useEffect(() => {
window.main.setCurrentOrganizationId(organizationId);
return () => window.main.setCurrentOrganizationId(undefined);
@@ -243,8 +235,7 @@ const Component = () => {
});
}, [userSession.accountId]);
const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || [];
const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || [];
const { untrackedProjects, untrackedWorkspaces } = useUntrackedProjects();
const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0;
const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID;
const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId);
@@ -1,54 +0,0 @@
import type { Organization } from 'insomnia-api';
import type { Project, Workspace } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { createFetcherLoadHook } from '~/ui/utils/router';
import type { Route } from './+types/untracked-projects';
export interface UntrackedProjectsLoaderData {
untrackedProjects: (Project & { workspacesCount: number })[];
untrackedWorkspaces: Workspace[];
}
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const { accountId } = await services.userSession.get();
const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
const listOfOrganizationIds = [
...organizations.map(o => o.id),
models.organization.SCRATCHPAD_ORGANIZATION_ID,
models.organization.getKonnectOrganizationId(accountId),
];
const projects = await services.project.list({
parentId: { $nin: listOfOrganizationIds },
});
const untrackedProjects = [];
for (const project of projects) {
const workspacesCount = await services.workspace.count({
parentId: project._id,
});
untrackedProjects.push({
...project,
workspacesCount,
});
}
const untrackedWorkspaces = await services.workspace.list({
parentId: null,
});
return {
untrackedProjects,
untrackedWorkspaces,
};
}
export const useUntrackedProjectsLoaderFetcher = createFetcherLoadHook(
load => () => {
return load('/untracked-projects');
},
clientLoader,
);
@@ -1,8 +1,5 @@
import { format } from 'date-fns';
import {
convertApiSpecSyntax,
detectApiSpecSyntax,
} from 'insomnia/src/common/api-specs';
import { convertApiSpecSyntax, detectApiSpecSyntax } from 'insomnia/src/common/api-specs';
import { getProductName } from 'insomnia/src/common/constants';
import { getWorkspaceLabel } from 'insomnia/src/common/get-workspace-label';
import { getInsomniaV5DataExport } from 'insomnia/src/common/insomnia-v5';
@@ -26,13 +23,13 @@ import { useProjectListWorkspacesLoaderFetcher } from '~/routes/organization.$or
import { useProjectMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move';
import { useProjectMoveWorkspaceActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move-workspace';
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { ImportProjectsModal } from '~/ui/components/modals/import-modal/import-projects-modal';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import { usePlanData } from '~/ui/hooks/use-plan';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import { useUntrackedProjects } from '~/ui/hooks/use-untracked-projects';
const VALUE_YAML = 'yaml';
const VALUE_JSON = 'json';
@@ -714,17 +711,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
const { features } = useOrganizationPermissions();
const { isEnterprisePlan } = usePlanData();
const untrackedProjectsFetcher = useUntrackedProjectsLoaderFetcher();
useEffect(() => {
const isIdleAndUninitialized = untrackedProjectsFetcher.state === 'idle' && !untrackedProjectsFetcher.data;
if (isIdleAndUninitialized) {
untrackedProjectsFetcher.load();
}
}, [untrackedProjectsFetcher, organizationId]);
const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || [];
const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || [];
const { untrackedProjects, untrackedWorkspaces } = useUntrackedProjects();
const workspaceData = useWorkspaceLoaderData();
const activeWorkspaceName = workspaceData?.activeWorkspace.name;
@@ -755,8 +742,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
const activeProject = projectLoaderData?.activeProject;
const projectName = activeProject?.name ?? getProductName();
const projects = projectLoaderData?.projects || [];
const organizationName =
organizations.find(org => org.id === organizationId)?.name || 'Organization';
const organizationName = organizations.find(org => org.id === organizationId)?.name || 'Organization';
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isImportProjectsModalOpen, setIsImportProjectsModalOpen] = useState(false);
@@ -0,0 +1,31 @@
import { models } from 'insomnia-data';
import { useMemo } from 'react';
import { type UntrackedProjectsData, untrackedProjectsQuery } from '~/common/app-data';
import { useRootLoaderData } from '~/root';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useDBQuery } from '~/ui/hooks/use-query';
const emptyData: UntrackedProjectsData = { untrackedProjects: [], untrackedWorkspaces: [] };
/** Projects that belong to none of the account's organizations, and workspaces that belong to no project. */
export function useUntrackedProjects(): UntrackedProjectsData {
const { userSession } = useRootLoaderData()!;
const organizations = useOrganizations();
const accountId = userSession.accountId;
const trackedOrganizationIds = useMemo(
() =>
[
...new Set([
...organizations.map(organization => organization.id),
models.organization.SCRATCHPAD_ORGANIZATION_ID,
models.organization.getKonnectOrganizationId(accountId),
]),
].sort(),
[organizations, accountId],
);
const { data } = useDBQuery(untrackedProjectsQuery(trackedOrganizationIds));
return data ?? emptyData;
}