From 54f608bb523ac9b1914d185123d9f9274c7a2788 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Tue, 9 Jun 2026 15:49:09 +0800 Subject: [PATCH 1/6] initial check-in for using tanstack query --- package-lock.json | 29 +++++++++++++++++++ packages/insomnia/package.json | 3 +- packages/insomnia/src/routes/organization.tsx | 6 +++- .../project-navigation-sidebar-utils.ts | 24 +++++++++++++++ .../project-navigation-sidebar/types.ts | 20 +++++-------- packages/insomnia/src/ui/db-query-client.ts | 14 +++++++++ 6 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 packages/insomnia/src/ui/db-query-client.ts diff --git a/package-lock.json b/package-lock.json index 7340c411863..e8ec5cd66ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9820,6 +9820,34 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", @@ -29322,6 +29350,7 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "7.13.2", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 53ae7bee315..65f8d98dcd1 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -72,8 +72,8 @@ "acorn": "^8.16.0", "acorn-walk": "^8.3.5", "ajv": "^8.17.1", - "assert": "^2.1.0", "apiconnect-wsdl": "2.0.36", + "assert": "^2.1.0", "aws4": "^1.13.2", "blakejs": "^1.2.1", "buffer": "^6.0.3", @@ -158,6 +158,7 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "7.13.2", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index afd35094fef..f41f169046c 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,3 +1,4 @@ +import { QueryClientProvider } from '@tanstack/react-query'; import { type Billing, type CurrentPlan, type FeatureList, type Organization, type User } from 'insomnia-api'; import type { Settings } from 'insomnia-data'; import { models, services } from 'insomnia-data'; @@ -28,6 +29,7 @@ import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-str import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context'; import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context'; import { RunnerProvider } from '~/ui/context/app/runner-context'; +import { dbQueryClient } from '~/ui/db-query-client'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; import type { AsyncTask } from '~/utils/router'; @@ -315,7 +317,9 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
- + + +
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 be6383c9f30..cf0c39d6bb7 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 @@ -50,6 +50,30 @@ export interface AllRequestsAndMetaInWorkspace { // created: r.created, // }); +// Document types whose changes can affect the sidebar tree. +export const SIDEBAR_RELEVANT_DOC_TYPES = [ + models.project.type, + models.workspace.type, + models.request.type, + models.grpcRequest.type, + models.webSocketRequest.type, + models.socketIORequest.type, + models.requestGroup.type, + models.requestMeta.type, + models.grpcRequestMeta.type, + models.webSocketRequestMeta.type, + models.socketIORequestMeta.type, + models.requestGroupMeta.type, +]; + +// Unique key for tanstack to query and cache projects, workspaces & collections +export const PROJECTS_QUERY_KEY = 'project-navigation-sidebar-projects'; +export const WORKSPACES_QUERY_KEY = 'project-navigation-sidebar-workspaces'; +export const COLLECTION_QUERY_KEY = 'project-navigation-sidebar-collection'; +export const projectsQueryKey = (organizationId: string) => [PROJECTS_QUERY_KEY, organizationId]; +export const workspacesQueryKey = (projectId: string) => [WORKSPACES_QUERY_KEY, projectId]; +export const collectionQueryKey = (workspaceId: string) => [COLLECTION_QUERY_KEY, workspaceId]; + export async function getWorkspacesByProjectIds(projectIds: string[]) { const workspaces = await database.find(models.workspace.type, { parentId: { $in: projectIds }, diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts index a633303cef5..fa3560626cb 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts @@ -3,19 +3,13 @@ import type { BaseModel, GitRepository, Project, RequestGroup, Workspace, Worksp import type { InsomniaFile } from '~/common/project'; import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -export type ProjectWithPresence = Project & { - gitRepository?: GitRepository; - presence: { - key: string; - alt: string; - src: string; - }[]; -}; - export interface WorkspaceSummary { workspace: Workspace; meta: WorkspaceMeta; } +export type ProjectWithGitRepository = Project & { + gitRepository?: GitRepository; +}; interface BaseFlatItem { // database doc associated with this item @@ -28,14 +22,14 @@ interface BaseFlatItem { organizationId: string; } -export interface ProjectFlatItem extends BaseFlatItem { +export interface ProjectFlatItem extends BaseFlatItem { kind: 'project'; } export interface WorkspaceFlatItem extends BaseFlatItem { kind: 'workspace'; // parent project - project: ProjectWithPresence; + project: ProjectWithGitRepository; } // Unsynced workspace in cloud sync project @@ -50,7 +44,7 @@ export type UnsyncedWorkspaceFlatItem = Omit, 'doc'> & export interface CollectionChildFlatItem extends BaseFlatItem { kind: 'collectionChild'; // parent project - project: ProjectWithPresence; + project: Project; // parent workspace workspace: Workspace; // nested children for request group @@ -77,7 +71,7 @@ export interface EmptyNodeFlatItem { hidden: boolean; organizationId: string; doc: { _id: string; name: string }; - project: ProjectWithPresence; + project: Project; workspace?: Workspace; requestGroup?: RequestGroup; level?: number; diff --git a/packages/insomnia/src/ui/db-query-client.ts b/packages/insomnia/src/ui/db-query-client.ts new file mode 100644 index 00000000000..2741918995e --- /dev/null +++ b/packages/insomnia/src/ui/db-query-client.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query'; + +// Single shared QueryClient instance for all database queries. +// Set default options for local db queries to never refetch or retry, and consider data always fresh (staleTime: Infinity). +export const dbQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + }, +}); From 11ade5d9b56ba798f6433b0d81ed61d88cfa9eef Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 11 Jun 2026 15:26:51 +0800 Subject: [PATCH 2/6] replace root loader --- package-lock.json | 4 +- packages/insomnia/package.json | 2 +- packages/insomnia/src/root.tsx | 25 ++-- ...d-credentials.$cloudCredentialId.delete.ts | 33 ----- ...d-credentials.$cloudCredentialId.update.ts | 60 --------- .../src/routes/cloud-credentials.create.tsx | 64 --------- packages/insomnia/src/routes/organization.tsx | 6 +- .../insomnia/src/routes/settings.update.tsx | 28 ---- packages/insomnia/src/routes/trial.check.tsx | 4 +- packages/insomnia/src/routes/trial.start.tsx | 4 +- packages/insomnia/src/ui/hooks/data/index.ts | 11 ++ .../insomnia/src/ui/hooks/data/query-keys.ts | 6 + .../ui/hooks/data/use-cloud-credentials.ts | 123 ++++++++++++++++++ .../hooks/data/use-invalidating-mutation.ts | 16 +++ .../src/ui/hooks/data/use-settings.ts | 28 ++++ .../src/ui/hooks/data/use-user-session.ts | 21 +++ packages/insomnia/src/ui/hooks/use-request.ts | 6 +- .../insomnia/src/ui/hooks/use-root-data.tsx | 54 ++++++++ 18 files changed, 282 insertions(+), 213 deletions(-) delete mode 100644 packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.delete.ts delete mode 100644 packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts delete mode 100644 packages/insomnia/src/routes/cloud-credentials.create.tsx delete mode 100644 packages/insomnia/src/routes/settings.update.tsx create mode 100644 packages/insomnia/src/ui/hooks/data/index.ts create mode 100644 packages/insomnia/src/ui/hooks/data/query-keys.ts create mode 100644 packages/insomnia/src/ui/hooks/data/use-cloud-credentials.ts create mode 100644 packages/insomnia/src/ui/hooks/data/use-invalidating-mutation.ts create mode 100644 packages/insomnia/src/ui/hooks/data/use-settings.ts create mode 100644 packages/insomnia/src/ui/hooks/data/use-user-session.ts create mode 100644 packages/insomnia/src/ui/hooks/use-root-data.tsx diff --git a/package-lock.json b/package-lock.json index e8ec5cd66ff..c61e9cc87a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9824,7 +9824,6 @@ "version": "5.101.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9835,7 +9834,6 @@ "version": "5.101.0", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", - "dev": true, "license": "MIT", "dependencies": { "@tanstack/query-core": "5.101.0" @@ -29256,6 +29254,7 @@ "@stoplight/spectral-ruleset-bundler": "1.7.0", "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "3.13.12", "@xmldom/xmldom": "^0.9.8", "acorn": "^8.16.0", @@ -29350,7 +29349,6 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "7.13.2", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 65f8d98dcd1..7cdb0b4254d 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -68,6 +68,7 @@ "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-virtual": "3.13.12", + "@tanstack/react-query": "^5.101.0", "@xmldom/xmldom": "^0.9.8", "acorn": "^8.16.0", "acorn-walk": "^8.3.5", @@ -158,7 +159,6 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "7.13.2", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.101.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index b19ac9dd4ff..44435e7054f 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -1,4 +1,6 @@ import { config } from '@fortawesome/fontawesome-svg-core'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import type { IpcRendererEvent } from 'electron'; import type { Settings, UserSession } from 'insomnia-data'; import { models, services } from 'insomnia-data'; @@ -19,7 +21,6 @@ import { useNavigate, useParams, useRevalidator, - useRouteLoaderData, } from 'react-router'; import { useLatest } from 'react-use'; @@ -30,7 +31,6 @@ import { plugins } from '~/plugins/renderer-bridge'; import { useAuthorizeActionFetcher } from '~/routes/auth.authorize'; import { useDefaultBrowserRedirectActionFetcher } from '~/routes/auth.default-browser-redirect'; import { useLogoutFetcher } from '~/routes/auth.logout'; -import { useCreateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.create'; import { GIT_PROVIDER_COMPLETE_SIGN_IN_FETCHER_KEY, useGitProviderCompleteSignInFetcher, @@ -48,6 +48,8 @@ import { SettingsModal } from '~/ui/components/modals/settings-modal'; import { Toaster } from '~/ui/components/toast-notification'; import { AppHooks } from '~/ui/containers/app-hooks'; import cssHref from '~/ui/css/styles.css?url'; +import { dbQueryClient } from '~/ui/db-query-client'; +import { useCreateCloudCredential, useSettings, useUserSession } from '~/ui/hooks/data'; import Modals from '~/ui/modals'; import type { Route } from './+types/root'; @@ -199,25 +201,25 @@ export const ErrorBoundary: FC = ({ error }) => { export interface RootLoaderData { settings: Settings; - workspaceCount: number; userSession: UserSession; } export const useRootLoaderData = () => { - return useRouteLoaderData('root'); + const settings = useSettings(); + const userSession = useUserSession(); + return { + settings, + userSession, + }; }; export async function clientLoader(_args: Route.ClientLoaderArgs) { const settings = await services.settings.get(); - const workspaceCount = await services.workspace.count(); const userSession = await services.userSession.get(); - const cloudCredentials = await services.cloudCredential.all(); return { settings, - workspaceCount, userSession, - cloudCredentials, }; } @@ -279,7 +281,10 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { - {children} + + {children} + +
@@ -369,7 +374,7 @@ const Root = () => { }; const [importObject, setImportObject] = useState({ type: 'clipboard', defaultValue: '' }); - const { submit: createCloudCredentials } = useCreateCloudCredentialActionFetcher(); + const { mutate: createCloudCredentials } = useCreateCloudCredential(); const { submit: authorizeSubmit } = useAuthorizeActionFetcher(); const { submit: redirectToDefaultBrowserSubmit } = useDefaultBrowserRedirectActionFetcher(); const { submit: gitProviderCompleteSignInSubmit } = useGitProviderCompleteSignInFetcher({ diff --git a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.delete.ts b/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.delete.ts deleted file mode 100644 index 24c382d4699..00000000000 --- a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.delete.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { services } from 'insomnia-data'; -import { href } from 'react-router'; - -import { invariant } from '~/utils/invariant'; -import { createFetcherSubmitHook } from '~/utils/router'; - -import type { Route } from './+types/cloud-credentials.$cloudCredentialId.delete'; - -export async function clientAction({ params }: Route.ClientActionArgs) { - const { cloudCredentialId } = params; - invariant(typeof cloudCredentialId === 'string', 'Cloud Credential ID is required'); - const cloudCredential = await services.cloudCredential.getById(cloudCredentialId); - invariant(cloudCredential, 'Cloud Credential not found'); - await services.cloudCredential.remove(cloudCredential); - return null; -} - -export const useDeleteCloudCredentialActionFetcher = createFetcherSubmitHook( - submit => - ({ cloudCredentialId }: { cloudCredentialId: string }) => { - return submit( - {}, - { - method: 'POST', - action: href('/cloud-credentials/:cloudCredentialId/delete', { - cloudCredentialId, - }), - encType: 'application/json', - }, - ); - }, - clientAction, -); diff --git a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts b/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts deleted file mode 100644 index 960a6bd9c69..00000000000 --- a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { CloudProviderCredential } from 'insomnia-data'; -import { services } from 'insomnia-data'; -import { href } from 'react-router'; - -import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; -import { plugins } from '~/plugins/renderer-bridge'; -import { invariant } from '~/utils/invariant'; -import { createFetcherSubmitHook } from '~/utils/router'; - -import type { Route } from './+types/cloud-credentials.$cloudCredentialId.update'; - -export async function clientAction({ params, request }: Route.ClientActionArgs) { - const { cloudCredentialId } = params; - invariant(typeof cloudCredentialId === 'string', 'Credential ID is required'); - const patch = (await request.json()) as CloudProviderCredential; - const { name, provider, credentials } = patch; - invariant(name && typeof name === 'string', 'Name is required'); - invariant(provider, 'Cloud Provider name is required'); - invariant(credentials, 'Credentials are required'); - const authenticateResponse = await plugins.executePluginMainAction({ - pluginName: EXTERNAL_VAULT_PLUGIN_NAME, - actionName: 'authenticate', - params: { provider, credentials }, - }); - const { success, error, result } = authenticateResponse as any; - if (error) { - return { - error: `${error.errorMessage}`, - }; - } - if (success) { - const originCredential = await services.cloudCredential.getById(cloudCredentialId); - invariant(originCredential, 'No Cloud Credential found'); - if (provider === 'hashicorp') { - // update access token and expires_at - const { access_token, expires_at } = result as { access_token: string; expires_at: number }; - if (patch.credentials) { - patch.credentials['access_token'] = access_token; - patch.credentials['expires_at'] = expires_at; - } - } - await services.cloudCredential.update(originCredential, patch); - return result as { access_token: string; expires_at: number }; - } - return { error: 'Unexpected response from ' + provider }; -} - -export const useUpdateCloudCredentialActionFetcher = createFetcherSubmitHook( - submit => - ({ cloudCredentialId, patch }: { cloudCredentialId: string; patch: Partial }) => { - return submit(JSON.stringify(patch), { - method: 'POST', - action: href('/cloud-credentials/:cloudCredentialId/update', { - cloudCredentialId, - }), - encType: 'application/json', - }); - }, - clientAction, -); diff --git a/packages/insomnia/src/routes/cloud-credentials.create.tsx b/packages/insomnia/src/routes/cloud-credentials.create.tsx deleted file mode 100644 index 4c056745b41..00000000000 --- a/packages/insomnia/src/routes/cloud-credentials.create.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { CloudProviderCredential } from 'insomnia-data'; -import { services } from 'insomnia-data'; -import { href } from 'react-router'; - -import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; -import { plugins } from '~/plugins/renderer-bridge'; -import { invariant } from '~/utils/invariant'; -import { createFetcherSubmitHook } from '~/utils/router'; - -import type { Route } from './+types/cloud-credentials.create'; - -type CreateCloudCredentialsData = Pick & { - isAuthenticated?: boolean; - provider: string; -}; - -export async function clientAction({ request }: Route.ClientActionArgs) { - const patch = await request.json(); - const { name, provider, credentials, isAuthenticated } = patch as CreateCloudCredentialsData; - invariant(name && typeof name === 'string', 'Name is required'); - invariant(provider, 'Cloud Provider name is required'); - invariant(credentials, 'Credentials are required'); - if (isAuthenticated) { - // find credential with same name for oauth authenticated cloud service - const existingCredential = await services.cloudCredential.getByName(name, provider); - await (existingCredential.length === 0 - ? services.cloudCredential.create(patch) - : services.cloudCredential.update(existingCredential[0], patch)); - return credentials; - } - const authenticateResponse = await plugins.executePluginMainAction({ - pluginName: EXTERNAL_VAULT_PLUGIN_NAME, - actionName: 'authenticate', - params: { provider, credentials }, - }); - const { success, error, result } = authenticateResponse as any; - if (error) { - return { - error: `${error.errorMessage}`, - }; - } - if (success) { - if (provider === 'hashicorp') { - // update access token and expires_at - const { access_token, expires_at } = result as { access_token: string; expires_at: number }; - patch.credentials['access_token'] = access_token; - patch.credentials['expires_at'] = expires_at; - } - await services.cloudCredential.create(patch); - return result as { access_token: string; expires_at: number }; - } - return { error: 'Unexpected response from ' + provider }; -} - -export const useCreateCloudCredentialActionFetcher = createFetcherSubmitHook( - submit => (data: CreateCloudCredentialsData) => { - return submit(JSON.stringify(data), { - method: 'POST', - action: href('/cloud-credentials/create'), - encType: 'application/json', - }); - }, - clientAction, -); diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index f41f169046c..afd35094fef 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,4 +1,3 @@ -import { QueryClientProvider } from '@tanstack/react-query'; import { type Billing, type CurrentPlan, type FeatureList, type Organization, type User } from 'insomnia-api'; import type { Settings } from 'insomnia-data'; import { models, services } from 'insomnia-data'; @@ -29,7 +28,6 @@ import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-str import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context'; import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context'; import { RunnerProvider } from '~/ui/context/app/runner-context'; -import { dbQueryClient } from '~/ui/db-query-client'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; import type { AsyncTask } from '~/utils/router'; @@ -317,9 +315,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
- - - +
diff --git a/packages/insomnia/src/routes/settings.update.tsx b/packages/insomnia/src/routes/settings.update.tsx deleted file mode 100644 index f09e5c791ae..00000000000 --- a/packages/insomnia/src/routes/settings.update.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Settings } from 'insomnia-data'; -import { services } from 'insomnia-data'; - -import { AnalyticsEvent } from '~/ui/analytics'; -import { createFetcherSubmitHook } from '~/utils/router'; - -import type { Route } from './+types/settings.update'; - -export async function clientAction({ request }: Route.ClientActionArgs) { - const patch = (await request.json()) as Partial; - if ('enableAnalytics' in patch && !patch.enableAnalytics) { - window.main.trackAnalyticsEvent({ event: AnalyticsEvent.analyticsDisabled }); - } - await services.settings.patch(patch); - return null; -} - -export const useSettingsUpdateActionFetcher = createFetcherSubmitHook( - submit => - ({ patch }: { patch: Partial }) => { - return submit(JSON.stringify(patch), { - method: 'POST', - action: '/settings/update', - encType: 'application/json', - }); - }, - clientAction, -); diff --git a/packages/insomnia/src/routes/trial.check.tsx b/packages/insomnia/src/routes/trial.check.tsx index cd2ceda865a..b1afe565377 100644 --- a/packages/insomnia/src/routes/trial.check.tsx +++ b/packages/insomnia/src/routes/trial.check.tsx @@ -4,9 +4,7 @@ import { href } from 'react-router'; import { createFetcherLoadHook } from '~/utils/router'; -import type { Route } from './+types/settings.update'; - -export async function clientLoader(_args: Route.ClientLoaderArgs) { +export async function clientLoader() { const { id: sessionId } = await services.userSession.get(); if (!sessionId) { diff --git a/packages/insomnia/src/routes/trial.start.tsx b/packages/insomnia/src/routes/trial.start.tsx index e8ef3eebe7f..4306dcbb3e0 100644 --- a/packages/insomnia/src/routes/trial.start.tsx +++ b/packages/insomnia/src/routes/trial.start.tsx @@ -4,9 +4,7 @@ import { services } from 'insomnia-data'; import { syncCurrentPlan } from '~/ui/organization-utils'; import { createFetcherSubmitHook } from '~/utils/router'; -import type { Route } from './+types/settings.update'; - -export async function clientAction(_args: Route.ClientActionArgs) { +export async function clientAction() { const { id: sessionId, accountId } = await services.userSession.get(); if (!sessionId || !accountId) { diff --git a/packages/insomnia/src/ui/hooks/data/index.ts b/packages/insomnia/src/ui/hooks/data/index.ts new file mode 100644 index 00000000000..7a40ea02021 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/index.ts @@ -0,0 +1,11 @@ +export { queryKeys } from './query-keys'; +export { useInvalidatingMutation } from './use-invalidating-mutation'; + +export { useSettings, useUpdateSettings } from './use-settings'; +export { useUserSession, useUpdateUserSession } from './use-user-session'; +export { + useCloudCredentials, + useCreateCloudCredential, + useDeleteCloudCredential, + useUpdateCloudCredential, +} from './use-cloud-credentials'; diff --git a/packages/insomnia/src/ui/hooks/data/query-keys.ts b/packages/insomnia/src/ui/hooks/data/query-keys.ts new file mode 100644 index 00000000000..e3b7774f7e9 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/query-keys.ts @@ -0,0 +1,6 @@ +// Single source of truth for TanStack Query keys. +export const queryKeys = { + settings: () => ['settings'], + userSession: () => ['userSession'], + cloudCredentials: () => ['cloudCredentials'], +}; diff --git a/packages/insomnia/src/ui/hooks/data/use-cloud-credentials.ts b/packages/insomnia/src/ui/hooks/data/use-cloud-credentials.ts new file mode 100644 index 00000000000..b27c316034a --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/use-cloud-credentials.ts @@ -0,0 +1,123 @@ +import { useQuery } from '@tanstack/react-query'; +import { type CloudProviderCredential, services } from 'insomnia-data'; + +import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; +import { plugins } from '~/plugins/renderer-bridge'; +import { invariant } from '~/utils/invariant'; + +import { queryKeys } from './query-keys'; +import { useInvalidatingMutation } from './use-invalidating-mutation'; + +type CreateCloudCredentialData = Pick & { + isAuthenticated?: boolean; + provider: string; +}; + +export const useCloudCredentials = () => { + const { data } = useQuery({ + queryKey: queryKeys.cloudCredentials(), + queryFn: () => services.cloudCredential.all(), + initialData: [], + }); + return data; +}; + +export const useCreateCloudCredential = () => + useInvalidatingMutation({ + mutationFn: async (patch: CreateCloudCredentialData) => { + const { name, provider, credentials, isAuthenticated } = patch; + invariant(name && typeof name === 'string', 'Name is required'); + invariant(provider, 'Cloud Provider name is required'); + invariant(credentials, 'Credentials are required'); + + if (isAuthenticated) { + // find credential with same name for oauth authenticated cloud service + const existingCredential = await services.cloudCredential.getByName(name, provider); + await (existingCredential.length === 0 + ? services.cloudCredential.create(patch as Partial) + : services.cloudCredential.update(existingCredential[0], patch as Partial)); + return credentials; + } + + const authenticateResponse = await plugins.executePluginMainAction({ + pluginName: EXTERNAL_VAULT_PLUGIN_NAME, + actionName: 'authenticate', + params: { provider, credentials }, + }); + const { success, error, result } = authenticateResponse as any; + if (error) { + return { + error: `${error.errorMessage}`, + }; + } + if (success) { + if (provider === 'hashicorp') { + // update access token and expires_at + const { access_token, expires_at } = result as { access_token: string; expires_at: number }; + const credentialsRecord = patch.credentials as unknown as Record; + credentialsRecord['access_token'] = access_token; + credentialsRecord['expires_at'] = expires_at; + } + await services.cloudCredential.create(patch as Partial); + return result as { access_token: string; expires_at: number }; + } + return { error: 'Unexpected response from ' + provider }; + }, + invalidates: [queryKeys.cloudCredentials()], + }); + +export const useUpdateCloudCredential = () => + useInvalidatingMutation({ + mutationFn: async ({ + cloudCredentialId, + patch, + }: { + cloudCredentialId: string; + patch: Partial; + }) => { + const { name, provider, credentials } = patch; + invariant(name && typeof name === 'string', 'Name is required'); + invariant(provider, 'Cloud Provider name is required'); + invariant(credentials, 'Credentials are required'); + + const authenticateResponse = await plugins.executePluginMainAction({ + pluginName: EXTERNAL_VAULT_PLUGIN_NAME, + actionName: 'authenticate', + params: { provider, credentials }, + }); + const { success, error, result } = authenticateResponse as any; + if (error) { + return { + error: `${error.errorMessage}`, + }; + } + if (success) { + const originCredential = await services.cloudCredential.getById(cloudCredentialId); + invariant(originCredential, 'No Cloud Credential found'); + if (provider === 'hashicorp') { + // update access token and expires_at + const { access_token, expires_at } = result as { access_token: string; expires_at: number }; + if (patch.credentials) { + patch.credentials['access_token'] = access_token; + patch.credentials['expires_at'] = expires_at; + } + } + await services.cloudCredential.update(originCredential, patch); + return result as { access_token: string; expires_at: number }; + } + return { error: 'Unexpected response from ' + provider }; + }, + invalidates: [queryKeys.cloudCredentials()], + }); + +export const useDeleteCloudCredential = () => + useInvalidatingMutation({ + mutationFn: async (cloudCredentialId: string) => { + invariant(typeof cloudCredentialId === 'string', 'Cloud Credential ID is required'); + const cloudCredential = await services.cloudCredential.getById(cloudCredentialId); + invariant(cloudCredential, 'Cloud Credential not found'); + await services.cloudCredential.remove(cloudCredential); + return { name: cloudCredential.name, provider: cloudCredential.provider }; + }, + invalidates: [queryKeys.cloudCredentials()], + }); diff --git a/packages/insomnia/src/ui/hooks/data/use-invalidating-mutation.ts b/packages/insomnia/src/ui/hooks/data/use-invalidating-mutation.ts new file mode 100644 index 00000000000..a65787cd579 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/use-invalidating-mutation.ts @@ -0,0 +1,16 @@ +import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'; +export function useInvalidatingMutation(options: { + mutationFn: (vars: TVars) => Promise; + invalidates: readonly QueryKey[] | ((data: TData, vars: TVars) => readonly QueryKey[]); +}) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: options.mutationFn, + onSuccess: (data, vars) => { + const keys = typeof options.invalidates === 'function' + ? options.invalidates(data, vars) + : options.invalidates; + keys.forEach(queryKey => queryClient.invalidateQueries({ queryKey })); + }, + }); +} diff --git a/packages/insomnia/src/ui/hooks/data/use-settings.ts b/packages/insomnia/src/ui/hooks/data/use-settings.ts new file mode 100644 index 00000000000..7d0c7f19cdc --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/use-settings.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { services, type Settings } from 'insomnia-data'; +import { useRouteLoaderData } from 'react-router'; + +import { AnalyticsEvent } from '~/ui/analytics'; + +import { queryKeys } from './query-keys'; +import { useInvalidatingMutation } from './use-invalidating-mutation'; + +export const useSettings = () => { + const { data } = useQuery({ + queryKey: queryKeys.settings(), + queryFn: async () => await services.settings.get(), + initialData: useRouteLoaderData('root')!.settings as Settings, + }); + return data; +}; + +export const useUpdateSettings = () => + useInvalidatingMutation({ + mutationFn: async (patch: Partial) => { + if ('enableAnalytics' in patch && !patch.enableAnalytics) { + window.main.trackAnalyticsEvent({ event: AnalyticsEvent.analyticsDisabled }); + } + return services.settings.patch(patch); + }, + invalidates: [queryKeys.settings()], + }); diff --git a/packages/insomnia/src/ui/hooks/data/use-user-session.ts b/packages/insomnia/src/ui/hooks/data/use-user-session.ts new file mode 100644 index 00000000000..79b671e3714 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/use-user-session.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { services, type UserSession } from 'insomnia-data'; +import { useRouteLoaderData } from 'react-router'; + +import { queryKeys } from './query-keys'; +import { useInvalidatingMutation } from './use-invalidating-mutation'; + +export const useUserSession = () => { + const { data } = useQuery({ + queryKey: queryKeys.userSession(), + queryFn: async () => await services.userSession.get(), + initialData: useRouteLoaderData('root')!.userSession as UserSession, + }); + return data; +}; + +export const useUpdateUserSession = () => + useInvalidatingMutation({ + mutationFn: (patch: Partial) => services.userSession.update(patch), + invalidates: [queryKeys.userSession()], + }); diff --git a/packages/insomnia/src/ui/hooks/use-request.ts b/packages/insomnia/src/ui/hooks/use-request.ts index 32d56e48da8..86fb71ecaeb 100644 --- a/packages/insomnia/src/ui/hooks/use-request.ts +++ b/packages/insomnia/src/ui/hooks/use-request.ts @@ -21,7 +21,7 @@ import { useRequestUpdatePayloadActionFetcher } from '~/routes/organization.$org import { useRequestGroupUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.$requestGroupId.update'; import { useRequestGroupUpdateMetaActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.$requestGroupId.update-meta'; import { useWorkspaceUpdateMetaActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.update-meta'; -import { useSettingsUpdateActionFetcher } from '~/routes/settings.update'; +import { useUpdateSettings } from '~/ui/hooks/data'; export const useRequestPatcher = (requestWorkspaceId = '') => { const { organizationId, projectId, workspaceId } = useParams() as { @@ -108,9 +108,9 @@ export const useRequestGroupMetaPatcher = (requestWorkspaceId = '') => { }; export const useSettingsPatcher = () => { - const fetcher = useSettingsUpdateActionFetcher(); + const { mutate: patchSettings } = useUpdateSettings(); return (patch: Partial) => { - fetcher.submit({ patch }); + patchSettings(patch); }; }; diff --git a/packages/insomnia/src/ui/hooks/use-root-data.tsx b/packages/insomnia/src/ui/hooks/use-root-data.tsx new file mode 100644 index 00000000000..f7c9fce6d65 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-root-data.tsx @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { services, type Settings, type UserSession } from 'insomnia-data'; + +import { useRootLoaderData } from '~/root'; +import { AnalyticsEvent } from '~/ui/analytics'; + +type RootDataTypes = 'settings' | 'userSession'; + +export const rootDataQueryKey = (type: RootDataTypes) => ['root', type]; +const rootDataMutationFns = { + settings: { + update: async (patch: Partial) => { + if ('enableAnalytics' in patch && !patch.enableAnalytics) { + window.main.trackAnalyticsEvent({ event: AnalyticsEvent.analyticsDisabled }); + } + return services.settings.patch(patch); + }, + }, + userSession: { + update: async (data: Partial) => await services.userSession.update(data), + }, +}; + +export const useRootData = (type: RootDataTypes) => { + const initialData = useRootLoaderData()!; + const { data } = useQuery({ + queryKey: rootDataQueryKey(type), + queryFn: async () => { + switch (type) { + case 'settings': { + return await services.settings.get(); + } + case 'userSession': { + return await services.userSession.get(); + } + default: { + throw new Error(`Unknown root data type: ${type}`); + } + } + }, + initialData: initialData[type], + }); + return data; +}; + +export const useRootDataPatcher = (type: RootDataTypes) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (patch: any) => rootDataMutationFns[type].update(patch), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rootDataQueryKey(type) }); + }, + }); +}; From 5ce84b93344ee75f1ed115adf04472f00842c973 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 11 Jun 2026 15:42:27 +0800 Subject: [PATCH 3/6] change forms --- .../cloud-credential-modal.tsx | 17 ++++++++--------- .../settings/cloud-service-credentials.tsx | 11 ++++------- .../ui/components/settings/import-export.tsx | 7 +++++-- .../external-vault/hashicorp-vault-form.tsx | 4 ++-- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx index 837660aa56d..cf69f1f0c8d 100644 --- a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx @@ -3,8 +3,7 @@ import { models } from 'insomnia-data'; import React, { useEffect, useState } from 'react'; import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; -import { useUpdateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.$cloudCredentialId.update'; -import { useCreateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.create'; +import { useCreateCloudCredential, useUpdateCloudCredential } from '~/ui/hooks/data'; import { EXTERNAL_VAULT_PLUGIN_NAME } from '../../../../common/constants'; import { plugins } from '../../../../plugins/renderer-bridge'; @@ -31,27 +30,27 @@ export const CloudCredentialModal = (props: CloudCredentialModalProps) => { const [manulInputUrl, setManualInputUrl] = useState(''); const providerDisplayName = getProviderDisplayName(provider); - const updateCloudCredentialsFetcher = useUpdateCloudCredentialActionFetcher(); - const createCloudCredentialsFetcher = useCreateCloudCredentialActionFetcher(); + const updateCloudCredentialsFetcher = useUpdateCloudCredential(); + const createCloudCredentialsFetcher = useCreateCloudCredential(); const isEditing = !!providerCredential; const upsertFetcher = isEditing ? updateCloudCredentialsFetcher : createCloudCredentialsFetcher; const fetchErrorMessage = upsertFetcher.data && 'error' in upsertFetcher.data ? upsertFetcher.data.error : ''; - const isLoading = upsertFetcher.state !== 'idle'; + const isLoading = upsertFetcher.isPending; const handleFormSubmit = (data: BaseCloudCredential & { isAuthenticated?: boolean }) => { const { name, credentials, isAuthenticated = false } = data; const patch = { name, credentials, provider } as Partial; if (isEditing) { - return updateCloudCredentialsFetcher.submit({ + return updateCloudCredentialsFetcher.mutate({ patch, cloudCredentialId: providerCredential._id, }); } - return createCloudCredentialsFetcher.submit({ + return createCloudCredentialsFetcher.mutate({ name, credentials, provider, @@ -96,12 +95,12 @@ export const CloudCredentialModal = (props: CloudCredentialModalProps) => { useEffect(() => { // close modal if submit success - if (upsertFetcher.data && !('error' in upsertFetcher.data) && upsertFetcher.state === 'idle') { + if (upsertFetcher.data && !('error' in upsertFetcher.data) && !upsertFetcher.isPending) { const newCredentialData = upsertFetcher.data; onClose(newCredentialData); onComplete && onComplete(newCredentialData); } - }, [upsertFetcher.data, upsertFetcher.state, onClose, onComplete]); + }, [upsertFetcher.data, upsertFetcher.isPending, onClose, onComplete]); return ( { const { isOwner, isEnterprisePlan } = usePlanData(); - const { cloudCredentials } = useRootLoaderData()!; + const cloudCredentials = useCloudCredentials()!; const [modalState, setModalState] = useState<{ show: boolean; provider: CloudProviderName; @@ -60,7 +59,7 @@ export const CloudServiceCredentialList = () => { authUrl?: string; }>(); const [isVaultPluginInstalled, setIsVaultPluginInstalled] = useState(false); - const deleteCredentialFetcher = useDeleteCloudCredentialActionFetcher(); + const deleteCredentialFetcher = useDeleteCloudCredential(); useEffect(() => { const checkVaultPlugin = async () => { const plugins = await pluginsBridge.getBundlePlugins(); @@ -76,9 +75,7 @@ export const CloudServiceCredentialList = () => { message: `Are you sure to delete ${name}?`, onDone: async (isYes: boolean) => { if (isYes) { - deleteCredentialFetcher.submit({ - cloudCredentialId: id, - }); + deleteCredentialFetcher.mutate(id); } }, }); diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index 7e24142ab35..808d03e9a13 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -659,7 +659,7 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => const workspaceData = useWorkspaceLoaderData(); const activeWorkspaceName = workspaceData?.activeWorkspace.name; - const { workspaceCount, userSession } = useRootLoaderData()!; + const { userSession } = useRootLoaderData()!; const workspacesFetcher = useProjectListWorkspacesLoaderFetcher(); useEffect(() => { const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data; @@ -686,7 +686,10 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportProjectsModalOpen, setIsImportProjectsModalOpen] = useState(false); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - + const [workspaceCount, setWorkspaceCount] = useState(0); + useEffect(() => { + services.workspace.count().then(setWorkspaceCount); + }, []); useEffect(() => { onModalChange?.(isImportModalOpen || isImportProjectsModalOpen || isExportModalOpen); }, [isImportModalOpen, isImportProjectsModalOpen, isExportModalOpen, onModalChange]); diff --git a/packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx b/packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx index b7ab9c1c51b..288e6835d09 100644 --- a/packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx +++ b/packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx @@ -2,8 +2,8 @@ import type { CloudProviderCredential } from 'insomnia-data'; import { models } from 'insomnia-data'; import React from 'react'; -import { useRootLoaderData } from '~/root'; import type { NunjucksParsedTag } from '~/templating/types'; +import { useCloudCredentials } from '~/ui/hooks/data'; import { HelpTooltip } from '../../help-tooltip'; import { @@ -25,7 +25,7 @@ const { HashiCorpCredentialType } = models.cloudCredential; const defaultKVVersion = 'v2'; export const HashiCorpVaultForm = (props: HashiCorpVaultFormProps) => { - const { cloudCredentials } = useRootLoaderData()!; + const cloudCredentials = useCloudCredentials(); const { formData, onChange, activeTagData } = props; const { secretName } = formData; From f0212c2ccef9ef433558c57213fdb1fea1816dec Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 11 Jun 2026 15:52:15 +0800 Subject: [PATCH 4/6] use tanstack query to replace loader data --- .../project-navigation-sidebar.tsx | 357 +++++++++--------- 1 file changed, 177 insertions(+), 180 deletions(-) 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 22c34595771..657c61395a3 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 @@ -1,6 +1,7 @@ +import { useQueries, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { StorageRules } from 'insomnia-api'; -import type { RequestGroup, Workspace } from 'insomnia-data'; +import type { BaseModel, RequestGroup, Workspace } from 'insomnia-data'; import { models, services } from 'insomnia-data'; import { type Dispatch, @@ -20,8 +21,14 @@ import * as reactUse from 'react-use'; import { Button as BasicButton } from '~/basic-components/button'; import type { SortOrder } from '~/common/constants'; +import type { ChangeBufferEvent } from '~/common/database'; import { fuzzyMatchAll } from '~/common/misc'; -import { getAllRemoteBackendProjectsOfOrg, getUnsyncedRemoteWorkspaces, type InsomniaFile } from '~/common/project'; +import { + getAllRemoteBackendProjectsOfOrg, + getProjectsWithGitRepositories, + getUnsyncedRemoteWorkspaces, + type InsomniaFile, +} from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; import type { SyncResult } from '~/konnect/sync'; import { useRootLoaderData } from '~/root'; @@ -35,21 +42,24 @@ import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-mo import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; -import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; -import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; import { Icon } from '../../icon'; import { type AllRequestsAndMetaInWorkspace, + COLLECTION_QUERY_KEY, + collectionQueryKey, filterCollection, flattenCollectionChildren, getAllRequestsAndMetaByWorkspace, getWorkspacesByProjectIds, + projectsQueryKey, + SIDEBAR_RELEVANT_DOC_TYPES, + workspacesQueryKey, } from './project-navigation-sidebar-utils'; import { ProjectNode } from './project-node'; import { PinnedHeaderNode, RequestNode } from './request-node'; @@ -167,19 +177,20 @@ const ProjectNavigationSidebarInner = ( requestId?: string; requestGroupId?: string; }; - const { userSession, settings } = useRootLoaderData()!; - const projectLoaderData = useProjectLoaderData()!; - const { projects, projectsSyncStatusPromise } = projectLoaderData; - const [checkAllProjectSyncStatus] = useLoaderDeferData>( - projectsSyncStatusPromise, - organizationId, - ); - const { presence } = useInsomniaEventStreamContext(); + const { settings } = useRootLoaderData()!; + const { projects: initialProjects } = useProjectLoaderData()!; + const queryClient = useQueryClient(); + // Source the project list from React Query rather than the loader to avoid unnecessary re-render + const { data: projects = [] } = useQuery({ + queryKey: projectsQueryKey(organizationId), + queryFn: async () => models.project.sortProjects(await getProjectsWithGitRepositories({ organizationId })), + initialData: initialProjects, + }); + const [searchParams, _setSearchParams] = useSearchParams(); const tabNavigate = useTabNavigate(); const [collectionSortOrders, setCollectionSortOrders] = useState>({}); - const [flatItems, setFlatItems] = useState([]); const [projectWorkspaceSortOrder, setProjectWorkspaceSortOrder] = useState>({}); const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState>(new Map()); // Customized workspace sort orders by projectId @@ -207,45 +218,23 @@ const ProjectNavigationSidebarInner = ( const [filterInputValue, setFilterInputValue] = useState(projectNavigationSidebarFilter || ''); // Debounce update filter reactUse.useDebounce(() => setProjectNavigationSidebarFilter(filterInputValue), 300, [filterInputValue]); - // ref to cache queried workspaces by project id - const cachedWorkspacesRef = useRef>(new Map()); - // ref to cache queried collection children (request & requestGroups) data and meta by workspace id - const cachedCollectionChildrenAndMetaRef = useRef>(new Map()); // ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests const isFetchingUnsyncedFilesRef = useRef(false); const syncKonnectProjectsAndNotifyRef = useRef<() => Promise>(async () => {}); const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID; - - const projectsWithPresence = useMemo( - () => - projects - .filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null) - .map(project => { - const projectPresence = presence - .filter(p => p.project === project.remoteId) - .filter(p => p.acct !== userSession.accountId) - .map(user => { - return { - key: user.acct, - alt: user.firstName || user.lastName ? `${user.firstName} ${user.lastName}` : user.acct, - src: user.avatar, - }; - }); - return { - ...project, - presence: projectPresence, - hasUncommittedOrUnpushedChanges: - checkAllProjectSyncStatus?.[project._id] || - project.gitRepository?.hasUncommittedChanges || - project.gitRepository?.hasUnpushedChanges, - }; - }), - [projects, isProjectTabActive, presence, checkAllProjectSyncStatus, userSession.accountId], + // show konnect or none-konnect projects based on selected tab + const activeProjects = useMemo( + () => projects.filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null), + [isProjectTabActive, projects], ); + const projectIds = useMemo(() => activeProjects.map(p => p._id), [activeProjects]); - const cloudSyncProjects = useMemo(() => projects.filter(p => models.project.isRemoteProject(p)), [projects]); + const cloudSyncProjects = useMemo( + () => activeProjects.filter(p => models.project.isRemoteProject(p)), + [activeProjects], + ); // Generate a stable string key to trigger getOrFetchUnsyncedFiles when the list of cloud sync projects changes. const cloudSyncProjectIdsKey = useMemo( () => @@ -374,6 +363,30 @@ const ProjectNavigationSidebarInner = ( } }; + // Given a changed request/group/meta document, find its parent workspaceIds + const findCollectionWorkspaceIdsForChangedDoc = useCallback( + (doc: BaseModel) => { + const workspaceIds: string[] = []; + const cachedCollections = queryClient.getQueriesData({ + queryKey: [COLLECTION_QUERY_KEY], + }); + for (const [queryKey, data] of cachedCollections) { + const workspaceId = queryKey[1] as string; + if (!data) { + continue; + } + if ( + doc.parentId === workspaceId || + data.allRequests.some(request => request._id === doc._id || request._id === doc.parentId) + ) { + workspaceIds.push(workspaceId); + } + } + return workspaceIds; + }, + [queryClient], + ); + useEffect(() => { if (projectNavigationSidebarFilter) { window.main.trackAnalyticsEvent({ @@ -392,56 +405,105 @@ const ProjectNavigationSidebarInner = ( return uiEventBus.on(CLOUD_SYNC_FILE_CHANGE, updateUnsyncedFiles); }, [getAllRemoteFilesByProjectId, organizationId]); + // Listen for local database changes and invalidate only the affected query keys, useEffect(() => { - // clear caches on any router data change to avoid showing stale data - cachedWorkspacesRef.current.clear(); - cachedCollectionChildrenAndMetaRef.current.clear(); - }, [projectLoaderData]); - - useEffect(() => { - const tryToGetWorkspacesFromCache = async (projectIds: string[]) => { - const uncachedProjectIds = projectIds.filter(id => !cachedWorkspacesRef.current.has(id)); - if (uncachedProjectIds.length > 0) { - const workspacesByProjectId = await getWorkspacesByProjectIds(uncachedProjectIds); - for (const [projectId, workspaces] of workspacesByProjectId.entries()) { - cachedWorkspacesRef.current.set(projectId, workspaces); + return window.main.on('db.changes', (_, changes: ChangeBufferEvent[]) => { + // Ids of the projects whose workspaces data need to be invalidated + const projectIdsToInvalidate: string[] = []; + // Ids of the workspaces whose collection data need to be invalidated + const workspaceIdsToInvalidate: string[] = []; + let shouldInvalidateProjectList = false; + + for (const [, doc] of changes) { + if (!SIDEBAR_RELEVANT_DOC_TYPES.includes(doc.type)) { + continue; } - } - return cachedWorkspacesRef.current; - }; - const tryToGetCollectionChildrenAndMetaFromCache = async (workspaceIds: string[]) => { - const uncachedWorkspaceIds = workspaceIds.filter(id => !cachedCollectionChildrenAndMetaRef.current.has(id)); - if (uncachedWorkspaceIds.length > 0) { - const collectionChildAndMetaByWorkspaceId = await getAllRequestsAndMetaByWorkspace(uncachedWorkspaceIds); - for (const [workspaceId, collectionChildrenAndMeta] of collectionChildAndMetaByWorkspaceId.entries()) { - cachedCollectionChildrenAndMetaRef.current.set(workspaceId, collectionChildrenAndMeta); + if (doc.type === models.project.type) { + // A project was mutated, need to refresh the whole project list + shouldInvalidateProjectList = true; + continue; + } + if (doc.type === models.workspace.type) { + // A workspace was mutated, refresh the workspace list under its parent project and the collection data. + projectIdsToInvalidate.push(doc.parentId); + continue; } + // request/requestGroup/meta was mutated, find the parent workspace and refresh its collection data. + findCollectionWorkspaceIdsForChangedDoc(doc).forEach(id => workspaceIdsToInvalidate.push(id)); } - return cachedCollectionChildrenAndMetaRef.current; - }; - - const buildWorkspaceAndCollectionData = async () => { - const items: FlatItem[] = []; - // Array of project and collection workspace ids that should get data from db + // Invalidate all affected queries at once after processing the whole batch of changes. + if (shouldInvalidateProjectList) { + queryClient.invalidateQueries({ queryKey: projectsQueryKey(organizationId) }); + } + projectIdsToInvalidate.forEach(projectId => + queryClient.invalidateQueries({ queryKey: workspacesQueryKey(projectId) }), + ); + workspaceIdsToInvalidate.forEach(workspaceId => + queryClient.invalidateQueries({ queryKey: collectionQueryKey(workspaceId) }), + ); + }); + }, [queryClient, organizationId, findCollectionWorkspaceIdsForChangedDoc]); + + // Cached of workspaces by parent projectId + const workspacesByProjectId = useQueries({ + queries: projectIds.map(projectId => ({ + queryKey: workspacesQueryKey(projectId), + queryFn: async () => (await getWorkspacesByProjectIds([projectId])).get(projectId) ?? [], + })), + combine: useCallback( + (results: UseQueryResult[]) => { + const map = new Map(); + projectIds.forEach((projectId, index) => map.set(projectId, results[index]?.data ?? [])); + return map; + }, + [projectIds], + ), + }); - const projectIds = projectsWithPresence.map(p => p._id); - const collectionWorkspaceIds: string[] = []; - const workspacesByProject = await tryToGetWorkspacesFromCache(projectIds); - projectIds.forEach(projectId => { - const workspaces = workspacesByProject.get(projectId) || []; - workspaces.forEach(wk => { - if ( - wk.scope === 'collection' && - // Fetch collection children and meta if 1) the workspace is expanded or 2) there is an active filter - (!!projectNavigationSidebarFilter || (expandedProjectAndWorkspaceIds || []).includes(wk._id)) - ) { - collectionWorkspaceIds.push(wk._id); + // Get the list of collection workspace ids that should be cached based on the current filter and expanded projects/workspaces. Only collection workspaces are cached because they are the only ones with requests/groups that need to be displayed in the tree — for non-collection workspaces we only display the workspace name, so no extra data fetching is needed. + const collectionWorkspaceIds = useMemo(() => { + const ids: string[] = []; + projectIds.forEach(projectId => { + (workspacesByProjectId.get(projectId) || []).forEach(workspace => { + if ( + workspace.scope === 'collection' && + (!!projectNavigationSidebarFilter || (expandedProjectAndWorkspaceIds || []).includes(workspace._id)) + ) { + ids.push(workspace._id); + } + }); + }); + return ids; + }, [projectIds, workspacesByProjectId, projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds]); + + // Cached collection workspace data including all requests/groups and their meta (e.g. for sorting) by workspaceId + const collectionByWorkspaceId = useQueries({ + queries: collectionWorkspaceIds.map(workspaceId => ({ + queryKey: collectionQueryKey(workspaceId), + queryFn: async () => (await getAllRequestsAndMetaByWorkspace([workspaceId])).get(workspaceId), + })), + combine: useCallback( + (results: UseQueryResult[]) => { + const map = new Map(); + collectionWorkspaceIds.forEach((workspaceId, index) => { + const data = results[index]?.data; + if (data) { + map.set(workspaceId, data); } }); - }); - const collectionChildrenAndMetaByWorkspaceId = - await tryToGetCollectionChildrenAndMetaFromCache(collectionWorkspaceIds); - for (const project of projectsWithPresence) { + return map; + }, + [collectionWorkspaceIds], + ), + }); + + // Build the flat tree synchronously from the cached query data + const flatItems = useMemo(() => { + const buildWorkspaceAndCollectionData = (): FlatItem[] => { + const items: FlatItem[] = []; + const workspacesByProject = workspacesByProjectId; + const collectionChildrenAndMetaByWorkspaceId = collectionByWorkspaceId; + for (const project of activeProjects) { const projectId = project._id; const isProjectCollapsed = !(expandedProjectAndWorkspaceIds ?? []).includes(projectId); items.push({ @@ -647,18 +709,19 @@ const ProjectNavigationSidebarInner = ( }); } - setFlatItems(items); + return items; }; - buildWorkspaceAndCollectionData(); + return buildWorkspaceAndCollectionData(); }, [ + workspacesByProjectId, + collectionByWorkspaceId, collectionSortOrders, projectWorkspaceSortOrder, expandedProjectAndWorkspaceIds, - isProjectTabActive, localWorkspaceOrders, organizationId, projectNavigationSidebarFilter, - projectsWithPresence, + activeProjects, unsyncedFilesByProjectId, ]); @@ -671,7 +734,7 @@ const ProjectNavigationSidebarInner = ( dropPosition: 'before' | 'after', ) => { const isMoveToDifferentProject = sourceProjectId !== targetProjectId; - const workspaces = cachedWorkspacesRef.current.get(targetProjectId) || []; + const workspaces = queryClient.getQueryData(workspacesQueryKey(targetProjectId)) || []; const currentWorkspaceSortOrder = projectWorkspaceSortOrder[targetProjectId] || 'type-manual'; // Get the base order of workspace before re-order const baseOrder = @@ -701,7 +764,7 @@ const ProjectNavigationSidebarInner = ( } setLocalWorkspaceOrders({ ...localWorkspaceOrders, [targetProjectId]: reordered }); }, - [projectWorkspaceSortOrder, setLocalWorkspaceOrders, localWorkspaceOrders], + [projectWorkspaceSortOrder, setLocalWorkspaceOrders, localWorkspaceOrders, queryClient], ); const toggleProjectOrWorkspace = useCallback( @@ -757,7 +820,7 @@ const ProjectNavigationSidebarInner = ( const requestGroupMetas = await Promise.all( requestGroupIds.map(requestGroupId => services.requestGroupMeta.getByParentId(requestGroupId)), ); - // Update the collapsed state of the toggled request groups. + // Resolve the next collapsed state for each toggled request group. const nextStates = requestGroupIds.map((requestGroupId, index) => { const requestGroupMeta = requestGroupMetas[index]; return { @@ -765,99 +828,33 @@ const ProjectNavigationSidebarInner = ( collapsed: collapsed ?? (requestGroupMeta ? !requestGroupMeta.collapsed : false), }; }); + const collapsedByRequestGroupId = new Map( + nextStates.map(({ requestGroupId, collapsed }) => [requestGroupId, collapsed]), + ); + // Optimistically update the cached collection so the tree re-renders immediately. + queryClient.setQueryData(collectionQueryKey(workspace._id), previous => { + if (!previous) { + return previous; + } + return { + ...previous, + requestGroupMetas: previous.requestGroupMetas.map(requestGroupMeta => + collapsedByRequestGroupId.has(requestGroupMeta.parentId) + ? { ...requestGroupMeta, collapsed: collapsedByRequestGroupId.get(requestGroupMeta.parentId)! } + : requestGroupMeta, + ), + }; + }); + + // Persist the change to database await Promise.all( nextStates.map(({ requestGroupId, collapsed }) => services.requestGroupMeta.updateOrCreateForParentId(requestGroupId, { collapsed }), ), ); - // Update the collapsed state in the cache. - cachedCollectionChildrenAndMetaRef.current.forEach(workspaceData => { - workspaceData.requestGroupMetas.forEach(requestGroupMeta => { - const nextState = nextStates.find(({ requestGroupId }) => requestGroupId === requestGroupMeta.parentId); - if (nextState) { - requestGroupMeta.collapsed = nextState.collapsed; - } - }); - }); - - setFlatItems(previousFlatItems => - nextStates.reduce((nextFlatItems, { requestGroupId, collapsed }) => { - const toggledChildren: { id: string; parentIsCollapsed: boolean }[] = []; - - return nextFlatItems.map(item => { - if (item.kind === 'collectionChild') { - const { children, doc } = item; - // Find toggled request group and update collapsed state - if (doc._id === requestGroupId) { - // Add all children of the toggled request group to the array to update their hidden state - toggledChildren.push( - ...(children?.map(child => ({ id: child.doc._id, parentIsCollapsed: collapsed })) ?? []), - ); - - return { - ...item, - collapsed, - hidden: false, - }; - } - - const matchedToggledChild = toggledChildren.find(tc => tc.id === item.doc._id); - if (matchedToggledChild) { - const { parentIsCollapsed } = matchedToggledChild; - if (models.requestGroup.isRequestGroupId(doc._id)) { - // Add children of the toggled child request group to the array to update their hidden state - const isToggledRequestGroupCollapsed = - parentIsCollapsed || - cachedCollectionChildrenAndMetaRef.current - .get(workspace._id) - ?.requestGroupMetas.find(rgm => rgm.parentId === doc._id)?.collapsed || - false; - toggledChildren.push( - ...(item.children?.map(child => ({ - id: child.doc._id, - parentIsCollapsed: isToggledRequestGroupCollapsed, - })) ?? []), - ); - } - - return { - ...item, - hidden: parentIsCollapsed, - }; - } - } - - if (item.kind === 'emptyFolder') { - const parentFolder = item.requestGroup; - const parentFolderId = parentFolder?._id; - const matchedToggledChild = toggledChildren.find(tc => tc.id === parentFolderId); - // Update the emptyFolder node hidden state based on its parent request group collapsed state. - if (parentFolderId === requestGroupId) { - return { - ...item, - hidden: collapsed, - }; - } else if (matchedToggledChild) { - return { - ...item, - hidden: - matchedToggledChild.parentIsCollapsed || - // If the parent folder is toggled to be expanded, the empty folder node should still be hidden if its parent request group is collapsed. - cachedCollectionChildrenAndMetaRef.current - .get(workspace._id) - ?.requestGroupMetas.find(rgm => rgm.parentId === matchedToggledChild.id)?.collapsed || - false, - }; - } - } - - return item; - }); - }, previousFlatItems), - ); }, - [projectNavigationSidebarFilter], + [projectNavigationSidebarFilter, queryClient], ); const parentRef = useRef(null); From 3f8a1390bcc58de67159d3c0fbc820c8f73a6169 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 11 Jun 2026 16:43:06 +0800 Subject: [PATCH 5/6] move loader data to project node --- .../project-node.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx index d85d81fbd74..e229d3295f7 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx @@ -3,7 +3,11 @@ import { models } from 'insomnia-data'; import { useState } from 'react'; import { Button } from 'react-aria-components'; +import { useRootLoaderData } from '~/root'; +import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { ProjectDropdown, type WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown'; +import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; +import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { AvatarGroup } from '../../avatar'; import { Icon } from '../../icon'; @@ -20,8 +24,30 @@ interface ProjectNodeProps { export const ProjectNode = ({ item, storageRules, onToggle, sortOrder, onSortOrderChange }: ProjectNodeProps) => { const { doc, collapsed, organizationId } = item; - const { name: projectName, presence, _id: projectId } = doc; + const { userSession } = useRootLoaderData()!; + const { presence } = useInsomniaEventStreamContext(); + const { name: projectName, _id: projectId } = doc; const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const { projectsSyncStatusPromise } = useProjectLoaderData()!; + const [checkAllProjectSyncStatus] = useLoaderDeferData>( + projectsSyncStatusPromise, + organizationId, + ); + const hasUncommittedOrUnpushedChanges = + checkAllProjectSyncStatus?.[projectId] || + doc.gitRepository?.hasUncommittedChanges || + doc.gitRepository?.hasUnpushedChanges; + + const projectPresence = presence + .filter(p => p.project === doc.remoteId) + .filter(p => p.acct !== userSession.accountId) + .map(user => { + return { + key: user.acct, + alt: user.firstName || user.lastName ? `${user.firstName} ${user.lastName}` : user.acct, + src: user.avatar, + }; + }); return (
{projectName}
- {presence.length > 0 && } + {projectPresence.length > 0 && } {projectId !== models.project.SCRATCHPAD_PROJECT_ID && ( Date: Fri, 12 Jun 2026 10:45:07 +0800 Subject: [PATCH 6/6] fix query key issues --- packages/insomnia/src/root.tsx | 6 +---- .../project-navigation-sidebar-utils.ts | 5 ++-- .../project-navigation-sidebar.tsx | 25 +++++-------------- packages/insomnia/src/ui/db-query-client.ts | 5 ++++ packages/insomnia/src/ui/hooks/data/index.ts | 2 ++ .../insomnia/src/ui/hooks/data/projects.ts | 22 ++++++++++++++++ 6 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 packages/insomnia/src/ui/hooks/data/projects.ts diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index 44435e7054f..c6f51461314 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -1,6 +1,5 @@ import { config } from '@fortawesome/fontawesome-svg-core'; import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import type { IpcRendererEvent } from 'electron'; import type { Settings, UserSession } from 'insomnia-data'; import { models, services } from 'insomnia-data'; @@ -281,10 +280,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { - - {children} - - + {children}
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 cf0c39d6bb7..e0348902808 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 @@ -66,11 +66,10 @@ export const SIDEBAR_RELEVANT_DOC_TYPES = [ models.requestGroupMeta.type, ]; -// Unique key for tanstack to query and cache projects, workspaces & collections -export const PROJECTS_QUERY_KEY = 'project-navigation-sidebar-projects'; +// Unique key for tanstack to query and cache workspaces & collections. +// The projects query key lives in the shared `~/ui/hooks/data` query-keys registry. export const WORKSPACES_QUERY_KEY = 'project-navigation-sidebar-workspaces'; export const COLLECTION_QUERY_KEY = 'project-navigation-sidebar-collection'; -export const projectsQueryKey = (organizationId: string) => [PROJECTS_QUERY_KEY, organizationId]; export const workspacesQueryKey = (projectId: string) => [WORKSPACES_QUERY_KEY, projectId]; export const collectionQueryKey = (workspaceId: string) => [COLLECTION_QUERY_KEY, workspaceId]; 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 657c61395a3..b0496611e06 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 @@ -1,4 +1,4 @@ -import { useQueries, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; +import { useQueries, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { StorageRules } from 'insomnia-api'; import type { BaseModel, RequestGroup, Workspace } from 'insomnia-data'; @@ -23,16 +23,9 @@ import { Button as BasicButton } from '~/basic-components/button'; import type { SortOrder } from '~/common/constants'; import type { ChangeBufferEvent } from '~/common/database'; import { fuzzyMatchAll } from '~/common/misc'; -import { - getAllRemoteBackendProjectsOfOrg, - getProjectsWithGitRepositories, - getUnsyncedRemoteWorkspaces, - type InsomniaFile, -} from '~/common/project'; +import { getAllRemoteBackendProjectsOfOrg, getUnsyncedRemoteWorkspaces, type InsomniaFile } from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; import type { SyncResult } from '~/konnect/sync'; -import { useRootLoaderData } from '~/root'; -import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { AnalyticsEvent } from '~/ui/analytics'; import type { WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown'; import { KongLogo } from '~/ui/components/kong-logo'; @@ -43,6 +36,7 @@ import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/em import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; +import { projectKeys, useProjects, useSettings } from '~/ui/hooks/data'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; import insomniaLogo from '~/ui/images/insomnia-logo.svg'; @@ -57,7 +51,6 @@ import { flattenCollectionChildren, getAllRequestsAndMetaByWorkspace, getWorkspacesByProjectIds, - projectsQueryKey, SIDEBAR_RELEVANT_DOC_TYPES, workspacesQueryKey, } from './project-navigation-sidebar-utils'; @@ -177,15 +170,9 @@ const ProjectNavigationSidebarInner = ( requestId?: string; requestGroupId?: string; }; - const { settings } = useRootLoaderData()!; - const { projects: initialProjects } = useProjectLoaderData()!; + const settings = useSettings(); const queryClient = useQueryClient(); - // Source the project list from React Query rather than the loader to avoid unnecessary re-render - const { data: projects = [] } = useQuery({ - queryKey: projectsQueryKey(organizationId), - queryFn: async () => models.project.sortProjects(await getProjectsWithGitRepositories({ organizationId })), - initialData: initialProjects, - }); + const { data: projects = [] } = useProjects(organizationId); const [searchParams, _setSearchParams] = useSearchParams(); const tabNavigate = useTabNavigate(); @@ -433,7 +420,7 @@ const ProjectNavigationSidebarInner = ( } // Invalidate all affected queries at once after processing the whole batch of changes. if (shouldInvalidateProjectList) { - queryClient.invalidateQueries({ queryKey: projectsQueryKey(organizationId) }); + queryClient.invalidateQueries({ queryKey: projectKeys.list(organizationId) }); } projectIdsToInvalidate.forEach(projectId => queryClient.invalidateQueries({ queryKey: workspacesQueryKey(projectId) }), diff --git a/packages/insomnia/src/ui/db-query-client.ts b/packages/insomnia/src/ui/db-query-client.ts index 2741918995e..c5a6c87ca72 100644 --- a/packages/insomnia/src/ui/db-query-client.ts +++ b/packages/insomnia/src/ui/db-query-client.ts @@ -12,3 +12,8 @@ export const dbQueryClient = new QueryClient({ }, }, }); + +if (typeof window !== 'undefined') { + // @ts-expect-error - Expose the dbQueryClient on the window object for debugging purposes. This is not intended for production use. + window.__TANSTACK_QUERY_CLIENT__ = dbQueryClient; +} diff --git a/packages/insomnia/src/ui/hooks/data/index.ts b/packages/insomnia/src/ui/hooks/data/index.ts index 7a40ea02021..e0e8f07d16c 100644 --- a/packages/insomnia/src/ui/hooks/data/index.ts +++ b/packages/insomnia/src/ui/hooks/data/index.ts @@ -1,6 +1,8 @@ export { queryKeys } from './query-keys'; export { useInvalidatingMutation } from './use-invalidating-mutation'; +export { type ProjectWithGitRepository, useProjects, projectKeys } from './projects'; + export { useSettings, useUpdateSettings } from './use-settings'; export { useUserSession, useUpdateUserSession } from './use-user-session'; export { diff --git a/packages/insomnia/src/ui/hooks/data/projects.ts b/packages/insomnia/src/ui/hooks/data/projects.ts new file mode 100644 index 00000000000..0e1cd55d0ff --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/projects.ts @@ -0,0 +1,22 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import type { GitRepository, Project } from 'insomnia-data'; +import { models } from 'insomnia-data'; + +import { getProjectsWithGitRepositories } from '~/common/project'; + +export type ProjectWithGitRepository = Project & { gitRepository?: GitRepository }; + +export const projectKeys = { + all: ['projects'], + list: (organizationId: string) => [...projectKeys.all, organizationId], +}; + +export const getProjects = async (organizationId: string): Promise => + models.project.sortProjects(await getProjectsWithGitRepositories({ organizationId })); + +export const useProjects = (organizationId: string): UseQueryResult => { + return useQuery({ + queryKey: projectKeys.list(organizationId), + queryFn: () => getProjects(organizationId), + }); +};