refactor: break route<->component circular dependencies in debug/mock routes

## Background

The debug and mock-route routes are imported back by the components they render: the gRPC panes import `GrpcRequestState` from the debug route, and the mock editors and panes import the loader hook and patcher from the mock-route route. The request update action also imports `updateMimeType` from the content-type dropdown, which pulls `use-request` back to the action route. Those edges close six route <-> component cycles.

## Changes

- Move `GrpcMessage`/`GrpcRequestState` to `ui/utils/grpc.ts`; repoint the debug route and the two gRPC panes.
- Move the mock-route loader hook and patcher into `ui/hooks/use-mock-route.ts`. The route keeps its `clientLoader` and `satisfies MockRouteLoaderData`; the components import the neutral hook. The patcher still uses the update action route's generated fetcher.
- Move the pure `updateMimeType`/`newBodyGraphQL` helpers from the content-type dropdown into `common/request-mime-type.ts`, so the action route no longer imports a UI component.
- Remove the six now-resolved entries from `scripts/circular-references/known-violations.json`.

## Testing

- `npm run check-cycle-references`: insomnia 19 -> 13 cycles, 0 new.
- `npm run type-check -w insomnia` and eslint on the changed files: clean.

## References

Follows #10496 in the circular-reference cleanup chain.
This commit is contained in:
Bingbing committed 2026-11-03 11:29:17 +08:00
1 parent 243e2b45b0
commit 010c37fa44
14 files changed
+146 -136

No files matched your search

@@ -0,0 +1,68 @@
import type { Request, RequestBody, RequestHeader, RequestParameter } from 'insomnia-data';
import { deconstructQueryStringToParams } from 'insomnia-data/common';
import {
CONTENT_TYPE_FILE,
CONTENT_TYPE_FORM_DATA,
CONTENT_TYPE_FORM_URLENCODED,
CONTENT_TYPE_GRAPHQL,
CONTENT_TYPE_JSON,
METHOD_POST,
} from '~/common/constants';
function newBodyGraphQL(rawBody: string): RequestBody {
try {
// Only strip the newlines if rawBody is a parsable JSON
JSON.parse(rawBody);
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody.replace(/\\\\n/g, ''),
};
} catch (error) {
if (error instanceof SyntaxError) {
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody,
};
}
throw error;
}
}
export const updateMimeType = (
request: Request,
mimeType: string | null,
): { body: RequestBody; headers: RequestHeader[]; params?: RequestParameter[]; method?: string } => {
const withoutContentType = request.headers.filter(h => h?.name?.toLowerCase() !== 'content-type');
// 'No body' selected
if (typeof mimeType !== 'string') {
return {
body: {},
headers: withoutContentType,
};
}
if (mimeType === CONTENT_TYPE_GRAPHQL) {
return {
body: newBodyGraphQL(request.body.text || ''),
headers: [{ name: 'Content-Type', value: CONTENT_TYPE_JSON }, ...withoutContentType],
method: METHOD_POST,
};
}
if (mimeType === CONTENT_TYPE_FORM_URLENCODED || mimeType === CONTENT_TYPE_FORM_DATA) {
const params = request.body.params || deconstructQueryStringToParams(request.body.text);
return {
body: { mimeType, params },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
}
if (mimeType === CONTENT_TYPE_FILE) {
return {
body: { mimeType, fileName: '' },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
}
return {
body: { mimeType: mimeType.split(';')[0], text: request.body.text || '' },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
};
@@ -2,9 +2,9 @@ import type { WebSocketRequest } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { href } from 'react-router';
import { updateMimeType } from '~/common/request-mime-type';
import { invariant } from '~/common/utils/invariant';
import { AnalyticsEvent } from '~/ui/analytics';
import { updateMimeType } from '~/ui/components/dropdowns/content-type-dropdown';
import { createFetcherSubmitHook } from '~/ui/utils/router';
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update';
@@ -1,4 +1,3 @@
import type { ServiceError, StatusObject } from '@grpc/grpc-js';
import type { ChangeBufferEvent, Request } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import type { OpenAPIV3 } from 'openapi-types';
@@ -8,7 +7,6 @@ import { href, redirect, useLoaderData, useMatch, useParams } from 'react-router
import YAML from 'yaml';
import { generateId } from '~/common/misc';
import type { GrpcMethodInfo } from '~/main/ipc/grpc';
import {
useWorkspaceLoaderData,
WORKSPACE_CONTENT_WRAPPER,
@@ -47,29 +45,17 @@ import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { type CreateRequestType, useRequestMetaPatcher } from '~/ui/hooks/use-request';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
import { getGrpcConnectionErrorDetails, isGrpcConnectionError } from '~/ui/utils/grpc';
import {
getGrpcConnectionErrorDetails,
type GrpcRequestState,
isGrpcConnectionError,
} from '~/ui/utils/grpc';
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug';
const { isEventStreamRequest, isGraphqlSubscriptionRequest, isRequest, isRequestId } = models.request;
const { isRequestGroupId } = models.requestGroup;
export interface GrpcMessage {
id: string;
text: string;
created: number;
}
export interface GrpcRequestState {
requestId: string;
running: boolean;
requestMessages: GrpcMessage[];
responseMessages: GrpcMessage[];
status?: StatusObject;
error?: ServiceError;
methods: GrpcMethodInfo[];
}
const INITIAL_GRPC_REQUEST_STATE = {
running: false,
requestMessages: [],
@@ -1,11 +1,10 @@
import type * as Har from 'har-format';
import { isApiError, upsertMockbin } from 'insomnia-api';
import type { MockRoute, MockServer, Request, RequestHeader, Response } from 'insomnia-data';
import type { Request, RequestHeader, Response } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { RESPONSE_CODE_REASONS } from 'insomnia-data/common';
import { useCallback } from 'react';
import { Button, Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-aria-components';
import { useParams, useRouteLoaderData } from 'react-router';
import { useParams } from 'react-router';
import {
CONTENT_TYPE_JSON,
@@ -21,7 +20,6 @@ import { database as db } from '~/common/database';
import { invariant } from '~/common/utils/invariant';
import { utf8ByteLength } from '~/common/utils/utf8-bytes';
import { useRequestNewMockSendActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new-mock-send';
import { useMockRouteUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.update';
import { AnalyticsEvent } from '~/ui/analytics';
import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor';
import { Dropdown, DropdownItem, ItemContent } from '~/ui/components/base/dropdown';
@@ -33,16 +31,11 @@ import { AlertModal } from '~/ui/components/modals/alert-modal';
import { EmptyStatePane } from '~/ui/components/panes/empty-state-pane';
import { Pane, PaneBody, PaneHeader } from '~/ui/components/panes/pane';
import { SvgIcon } from '~/ui/components/svg-icon';
import { type MockRouteLoaderData, useMockRouteLoaderData, useMockRoutePatcher } from '~/ui/hooks/use-mock-route';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
export interface MockRouteLoaderData {
mockServer: MockServer;
mockRoute: MockRoute;
activeResponse?: Response;
}
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceId, mockRouteId } = params;
@@ -73,7 +66,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
mockServer,
mockRoute,
activeResponse,
};
} satisfies MockRouteLoaderData;
}
const mockContentTypes = [
@@ -85,6 +78,7 @@ const mockContentTypes = [
];
export const isInMockContentTypeList = (contentType: string): boolean =>
Boolean(contentType && mockContentTypes.includes(contentType));
// mockbin expect a HAR response structure
export const mockRouteToHar = async ({
statusCode,
@@ -118,33 +112,6 @@ export const mockRouteToHar = async ({
};
};
export const useMockRoutePatcher = () => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
workspaceId: string;
};
const { submit } = useMockRouteUpdateActionFetcher();
return useCallback(
(id: string, patch: Partial<MockRoute>) => {
return submit({
mockRouteId: id,
organizationId,
projectId,
workspaceId,
patch,
});
},
[organizationId, projectId, submit, workspaceId],
);
};
export function useMockRouteLoaderData() {
return useRouteLoaderData<typeof clientLoader>(
'routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId',
);
}
export const MockRouteRoute = () => {
const { mockServer, mockRoute } = useMockRouteLoaderData()!;
@@ -1,6 +1,4 @@
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import type { Request, RequestBody, RequestHeader, RequestParameter } from 'insomnia-data';
import { deconstructQueryStringToParams } from 'insomnia-data/common';
import React, { type FC } from 'react';
import {
Button,
@@ -27,7 +25,6 @@ import {
CONTENT_TYPE_XML,
CONTENT_TYPE_YAML,
getContentTypeName,
METHOD_POST,
} from '../../../common/constants';
import {
type RequestLoaderData,
@@ -227,60 +224,3 @@ export const ContentTypeDropdown: FC = () => {
</Select>
);
};
export function newBodyGraphQL(rawBody: string): RequestBody {
try {
// Only strip the newlines if rawBody is a parsable JSON
JSON.parse(rawBody);
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody.replace(/\\\\n/g, ''),
};
} catch (error) {
if (error instanceof SyntaxError) {
return {
mimeType: CONTENT_TYPE_GRAPHQL,
text: rawBody,
};
}
throw error;
}
}
export const updateMimeType = (
request: Request,
mimeType: string | null,
): { body: RequestBody; headers: RequestHeader[]; params?: RequestParameter[]; method?: string } => {
const withoutContentType = request.headers.filter(h => h?.name?.toLowerCase() !== 'content-type');
// 'No body' selected
if (typeof mimeType !== 'string') {
return {
body: {},
headers: withoutContentType,
};
}
if (mimeType === CONTENT_TYPE_GRAPHQL) {
return {
body: newBodyGraphQL(request.body.text || ''),
headers: [{ name: 'Content-Type', value: CONTENT_TYPE_JSON }, ...withoutContentType],
method: METHOD_POST,
};
}
if (mimeType === CONTENT_TYPE_FORM_URLENCODED || mimeType === CONTENT_TYPE_FORM_DATA) {
const params = request.body.params || deconstructQueryStringToParams(request.body.text);
return {
body: { mimeType, params },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
}
if (mimeType === CONTENT_TYPE_FILE) {
return {
body: { mimeType, fileName: '' },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
}
return {
body: { mimeType: mimeType.split(';')[0], text: request.body.text || '' },
headers: [{ name: 'Content-Type', value: mimeType || '' }, ...withoutContentType],
};
};
@@ -4,11 +4,9 @@ import { Button } from 'react-aria-components';
import { useNavigate, useParams } from 'react-router';
import { useRequestLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import {
isInMockContentTypeList,
useMockRoutePatcher,
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
import { isInMockContentTypeList } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
import { useCurrentPlan } from '~/ui/hooks/use-account-server-data';
import { useMockRoutePatcher } from '~/ui/hooks/use-mock-route';
import { getContentTypeName, getMimeTypeFromContentType } from '../../../common/constants';
import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
@@ -2,11 +2,8 @@ import type { RequestHeader } from 'insomnia-data';
import React, { type FC, useCallback } from 'react';
import { useParams } from 'react-router';
import {
useMockRouteLoaderData,
useMockRoutePatcher,
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor';
import { useMockRouteLoaderData, useMockRoutePatcher } from '~/ui/hooks/use-mock-route';
import { getCommonHeaderNames, getCommonHeaderValues } from '../../../common/common-headers';
import { KeyValueEditor } from '../key-value-editor/key-value-editor';
@@ -8,8 +8,8 @@ import * as reactUse from 'react-use';
import { bodyBufferToUtf8 } from '~/common/utils/utf8-bytes';
import { useRequestNewMockSendActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new-mock-send';
import { useMockRouteLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor';
import { useMockRouteLoaderData } from '~/ui/hooks/use-mock-route';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import { jsonPrettify } from '~/ui/utils/prettify/json';
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Button } from 'react-aria-components';
import * as reactUse from 'react-use';
import { useMockRouteLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId';
import { useMockRouteLoaderData } from '~/ui/hooks/use-mock-route';
import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data';
import { getMockServiceBinURL } from '../../../common/constants';
@@ -23,7 +23,6 @@ import { getRenderedGrpcRequest, getRenderedGrpcRequestMessage } from '../../../
import type { GrpcMethodType } from '../../../main/ipc/grpc';
import { getOrInheritHeaders } from '../../../network/network';
import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import type { GrpcRequestState } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug';
import {
type GrpcRequestLoaderData,
useRequestLoaderData,
@@ -31,6 +30,7 @@ import {
import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context';
import { useRequestPatcher } from '../../hooks/use-request';
import { useGitVCSVersion } from '../../hooks/use-vcs-version';
import type { GrpcRequestState } from '../../utils/grpc';
import { GrpcSendButton } from '../buttons/grpc-send-button';
import { GrpcMethodDropdown } from '../dropdowns/grpc-method-dropdown/grpc-method-dropdown';
import { ErrorBoundary } from '../error-boundary';
@@ -3,7 +3,7 @@ import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor';
import type { GrpcRequestState } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug';
import type { GrpcRequestState } from '../../utils/grpc';
import { GrpcStatusTag } from '../tags/grpc-status-tag';
import { Pane, PaneBody, PaneHeader } from './pane';
interface Props {
@@ -0,0 +1,40 @@
import type { MockRoute, MockServer, Response } from 'insomnia-data';
import { useCallback } from 'react';
import { useParams, useRouteLoaderData } from 'react-router';
import { useMockRouteUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.update';
export interface MockRouteLoaderData {
mockServer: MockServer;
mockRoute: MockRoute;
activeResponse?: Response;
}
// Reads the mock-route route's loader data by route id so consumers (and the route itself) don't
// import the route module, which renders those consumers — importing it would close a
// route <-> component cycle. The route's `clientLoader` conforms to `MockRouteLoaderData`.
export const useMockRouteLoaderData = () =>
useRouteLoaderData(
'routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId',
) as MockRouteLoaderData | undefined;
export const useMockRoutePatcher = () => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
workspaceId: string;
};
const { submit } = useMockRouteUpdateActionFetcher();
return useCallback(
(id: string, patch: Partial<MockRoute>) => {
return submit({
mockRouteId: id,
organizationId,
projectId,
workspaceId,
patch,
});
},
[organizationId, projectId, submit, workspaceId],
);
};
+20
View File
@@ -1,3 +1,7 @@
import type { ServiceError, StatusObject } from '@grpc/grpc-js';
import type { GrpcMethodInfo } from '~/main/ipc/grpc';
const GRPC_CONNECTION_ERROR_STRINGS = {
SERVER_SELF_SIGNED: 'self signed certificate in certificate chain',
CLIENT_CERT_REQUIRED: 'CERTIFICATE_REQUIRED',
@@ -57,3 +61,19 @@ export function getGrpcConnectionErrorDetails(error: Error) {
message,
};
}
export interface GrpcMessage {
id: string;
text: string;
created: number;
}
export interface GrpcRequestState {
requestId: string;
running: boolean;
requestMessages: GrpcMessage[];
responseMessages: GrpcMessage[];
status?: StatusObject;
error?: ServiceError;
methods: GrpcMethodInfo[];
}
@@ -7,12 +7,6 @@
"packages/insomnia/src/main/plugin-window.ts -> packages/insomnia/src/main/window-utils.ts",
"packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/plugins/index.ts",
"packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/templating/index.ts -> packages/insomnia/src/plugins/index.ts",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx -> packages/insomnia/src/ui/components/dropdowns/content-type-dropdown.tsx -> packages/insomnia/src/ui/hooks/use-request.ts",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx -> packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx -> packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/editors/mock-response-headers-editor.tsx",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx",
"packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/mocks/mock-url-bar.tsx",
"packages/insomnia/src/sync/git/providers/github.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts",
"packages/insomnia/src/sync/git/providers/gitlab.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts",
"packages/insomnia/src/ui/components/modals/export-requests-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx",