refactor: account data fetching (#10463)

This commit is contained in:
Curry Yang authored and GitHub committed 2026-09-07 09:37:34 +00:00
1 parent 7c334de276
commit 511cd126c4
20 files changed
+197 -143

No files matched your search

+53 -16
View File
@@ -1,26 +1,63 @@
import { getCurrentPlan, getUserProfile } from 'insomnia-api';
import { type CurrentPlan, getCurrentPlan, getUserProfile, type Organization, type User } from 'insomnia-api';
import { services } from 'insomnia-data';
import { invariant } from '~/common/utils/invariant';
export interface OrganizationData {
organizations: Organization[];
user?: User;
currentPlan?: CurrentPlan;
}
// Fetches the organization triple (organizations / user / current plan) from the
// cloud API in parallel. Shared by both the TanStack query (the source of truth for
// React consumers) and `syncOrganizations` (the localStorage write path still used by
// the non-React readers). Throws if any of the three fail their invariant.
export async function fetchOrganizationData(sessionId: string): Promise<OrganizationData> {
const [organizations, user, currentPlan] = await Promise.all([
services.organization.list(),
getUserProfile({ sessionId }),
getCurrentPlan({ sessionId }),
]);
invariant(organizations, 'Failed to load organizations');
invariant(user && user.id, 'Failed to load user');
invariant(currentPlan && currentPlan.planId, 'Failed to load current plan');
return { organizations, user, currentPlan };
}
// Write-through helper: persists the organization triple to localStorage so the
// non-React readers (`getInitialEntry`, various loaders, event-stream handlers, etc.)
// keep working while the migration to TanStack Query is incremental.
export function writeOrganizationDataToLocalStorage(accountId: string, data: OrganizationData) {
invariant(accountId, 'Account ID is not defined');
localStorage.setItem(`${accountId}:spaces`, JSON.stringify(data.organizations));
localStorage.setItem(`${accountId}:user`, JSON.stringify(data.user));
localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(data.currentPlan));
}
// Reads the persisted triple back from localStorage. Used to seed the query's
// `initialData` so the first paint is instant (matching the old loader, which read
// localStorage synchronously). Defaults mirror the previous loader behaviour.
export function readOrganizationDataFromLocalStorage(accountId: string): OrganizationData {
const userRaw = localStorage.getItem(`${accountId}:user`);
const currentPlanRaw = localStorage.getItem(`${accountId}:currentPlan`);
return {
organizations: JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[],
// Leave these undefined (not `{}`) when the key is absent, so consumers can rely on the
// declared optional shape — casting `{}` to User/CurrentPlan hides missing fields from the
// type checker and causes runtime NPEs (e.g. `currentPlan.type.includes`).
user: userRaw ? (JSON.parse(userRaw) as User) : undefined,
currentPlan: currentPlanRaw ? (JSON.parse(currentPlanRaw) as CurrentPlan) : undefined,
};
}
// This is reusable action/loader implementations.
export async function syncOrganizations(sessionId: string, accountId: string) {
try {
const [organizations, user, currentPlan] = await Promise.all([
services.organization.list(),
getUserProfile({ sessionId }),
getCurrentPlan({ sessionId }),
]);
invariant(organizations, 'Failed to load organizations');
invariant(user && user.id, 'Failed to load user');
invariant(currentPlan && currentPlan.planId, 'Failed to load current plan');
invariant(accountId, 'Account ID is not defined');
localStorage.setItem(`${accountId}:spaces`, JSON.stringify(organizations));
localStorage.setItem(`${accountId}:user`, JSON.stringify(user));
localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlan));
const data = await fetchOrganizationData(sessionId);
writeOrganizationDataToLocalStorage(accountId, data);
} catch (error) {
console.log('[organization] Failed to load Organizations', error);
}
@@ -33,7 +33,6 @@ import { getAllLocalFiles } from '~/common/project';
import { sortMethodMap } from '~/common/sorting';
import { invariant } from '~/common/utils/invariant';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file';
import { useProjectLoaderData, useProjectRouteContext } from '~/routes/organization.$organizationId.project.$projectId';
import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new';
@@ -53,6 +52,7 @@ import { OrganizationTabList } from '~/ui/components/tabs/tab-list';
import { TimeFromNow } from '~/ui/components/time-from-now';
import { showResourceNotFoundToast } from '~/ui/components/toast-notification';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { useOrganizationData } from '~/ui/hooks/use-organization-data';
@@ -97,7 +97,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
)
.map(f => f.formData?.get('backendProjectId'));
const organizationData = useOrganizationLoaderData();
const organizations = useOrganizations();
const { presence } = useInsomniaEventStreamContext();
const { issuesByWorkspaceId } = useGitFileIssues();
const storageRules = useOrganizationStorageRule(organizationId);
@@ -120,7 +120,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
const [importModalType, setImportModalType] = useState<'file' | 'clipboard' | 'uri' | null>(null);
const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false);
const [isUpdateProjectModalOpen, setIsUpdateProjectModalOpen] = useState(false);
const organization = organizationData?.organizations.find(o => o.id === organizationId);
const organization = organizations.find(o => o.id === organizationId);
const isUserOwner = Boolean(organization?.is_owner);
const collectionItems = useMemo(
() =>
@@ -33,7 +33,6 @@ import { buildRunnerItemKey, type RunnerItemStatus, type RunnerLiveItem } from '
import { invariant } from '~/common/utils/invariant';
import { defaultSendActionRuntime } from '~/network/network';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import type { CollectionRunnerContext } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send';
import { sendActionImplementation } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send';
import { AnalyticsEvent } from '~/ui/analytics';
@@ -55,6 +54,7 @@ import { Tooltip } from '~/ui/components/tooltip';
import { ResponseTimelineViewer } from '~/ui/components/viewers/response-timeline-viewer';
import { useInsomniaTabContext } from '~/ui/context/app/insomnia-tab-context';
import { useRunnerContext } from '~/ui/context/app/runner-context';
import { useCurrentPlan } from '~/ui/hooks/use-account-server-data';
import { buildRunnerTabId } from '~/ui/hooks/use-insomnia-tab';
import { useRunnerRequestList } from '~/ui/hooks/use-runner-request-list';
import {
@@ -155,7 +155,7 @@ export const Runner: FC = () => {
const [searchParams] = useSearchParams();
const [errorMsg, setErrorMsg] = useState<null | string>(null);
const organizationData = useOrganizationLoaderData();
const currentPlan = useCurrentPlan();
const targetFolderId = searchParams.get('folder') || '';
const { organizationId, projectId, workspaceId } = useParams() as {
@@ -290,7 +290,7 @@ export const Runner: FC = () => {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.collectionRunExecute,
properties: { plan: organizationData?.currentPlan?.type || 'scratchpad', iterations: iterationCount },
properties: { plan: currentPlan?.type || 'scratchpad', iterations: iterationCount },
});
updateTabById?.(buildRunnerTabId(workspaceId, targetFolderId), { temporary: false });
@@ -2,7 +2,6 @@ import type { Organization } from 'insomnia-api';
import { services } from 'insomnia-data';
import { href, redirect } from 'react-router';
import { syncOrganizations } from '~/common/organization';
import { syncProjects } from '~/common/project';
import { invariant } from '~/common/utils/invariant';
import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization } from '~/ui/organization-utils';
@@ -27,12 +26,6 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
const { id: sessionId, accountId } = await services.userSession.get();
const taskPromiseList = [];
if (asyncTaskList.includes(AsyncTask.SyncOrganization)) {
invariant(sessionId, 'sessionId is required');
invariant(accountId, 'accountId is required');
taskPromiseList.push(syncOrganizations(sessionId, accountId));
}
if (asyncTaskList.includes(AsyncTask.MigrateProjects)) {
const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
invariant(organizations.length, 'Failed to fetch organizations.');
@@ -1,29 +0,0 @@
import { services } from 'insomnia-data';
import { syncOrganizations } from '~/common/organization';
import { createFetcherSubmitHook } from '~/ui/utils/router';
import type { Route } from './+types/organization.sync';
export async function clientAction(_args: Route.ClientActionArgs) {
const { id: sessionId, accountId } = await services.userSession.get();
if (sessionId) {
await syncOrganizations(sessionId, accountId);
}
return null;
}
export const useOrganizationSyncActionFetcher = createFetcherSubmitHook(
submit => () => {
return submit(
{},
{
method: 'POST',
action: '/organization/sync',
},
);
},
clientAction,
);
+8 -41
View File
@@ -1,9 +1,9 @@
import { type Billing, type CurrentPlan, type FeatureList, type Organization, type User } from 'insomnia-api';
import { type CurrentPlan, type User } from 'insomnia-api';
import type { Settings } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { models } from 'insomnia-data';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components';
import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router';
import { href, NavLink, Outlet, useLocation, useNavigate, useParams } from 'react-router';
import * as reactUse from 'react-use';
import { useRootLoaderData } from '~/root';
@@ -30,45 +30,10 @@ 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 { useCurrentPlan, useCurrentUser, useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useCloseConnection } from '~/ui/hooks/use-close-connection';
import type { AsyncTask } from '~/ui/utils/router';
import type { Route } from './+types/organization';
export interface OrganizationLoaderData {
organizations: Organization[];
user?: User;
currentPlan?: CurrentPlan;
}
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const { id, accountId } = await services.userSession.get();
if (id) {
const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as User;
const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan;
return {
organizations,
user,
currentPlan,
};
}
return {
organizations: [],
user: undefined,
currentPlan: undefined,
};
}
export interface OrganizationFeatureLoaderData {
featuresPromise: Promise<FeatureList>;
billingPromise: Promise<Billing>;
}
export const useOrganizationLoaderData = () => {
return useRouteLoaderData<typeof clientLoader>('routes/organization');
};
interface IndicatorProps {
asyncTaskStatus: 'error' | 'idle' | 'loading' | 'submitting';
settings: Settings;
@@ -194,8 +159,10 @@ const LoginUserActions = ({
);
};
const Component = ({ loaderData }: Route.ComponentProps) => {
const { organizations, user, currentPlan } = loaderData;
const Component = () => {
const organizations = useOrganizations();
const user = useCurrentUser();
const currentPlan = useCurrentPlan();
const { settings } = useRootLoaderData()!;
const workspaceData = useWorkspaceLoaderData();
@@ -3,12 +3,12 @@ import React, { useState } from 'react';
import { Button } from 'react-aria-components';
import { useNavigate, useParams } from 'react-router';
import { useOrganizationLoaderData } from '~/routes/organization';
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 { useCurrentPlan } from '~/ui/hooks/use-account-server-data';
import { getContentTypeName, getMimeTypeFromContentType } from '../../../common/constants';
import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
@@ -23,8 +23,8 @@ export const MockResponseExtractor = () => {
const { activeProject, activeWorkspace } = useWorkspaceLoaderData()!;
const isLocalProject = !activeProject?.remoteId;
const { currentPlan } = useOrganizationLoaderData()!;
const isEnterprise = currentPlan?.type.includes('enterprise');
const currentPlan = useCurrentPlan();
const isEnterprise = currentPlan?.type?.includes('enterprise');
// In a local project, users are not allowed to create a cloud mock server, only enterprise users can create a self-hosted mock server.
// In a local project, users without enterprise plan can't create cloud mock server route from a request response
@@ -10,6 +10,7 @@ import { useTrialStartActionFetcher } from '~/routes/trial.start';
import { Icon } from '~/ui/components/icon';
import { TrialConfirmationModal } from '~/ui/components/modals/trial-confirmation-modal';
import { Tooltip } from '~/ui/components/tooltip';
import { useInvalidateAccountData } from '~/ui/hooks/use-account-server-data';
import { usePlanData } from '~/ui/hooks/use-plan';
import { useUserService } from '~/ui/hooks/use-user-service';
import { formatNumber } from '~/ui/utils';
@@ -29,6 +30,7 @@ export const HeaderPlanIndicator = ({ isMinimal }: Props) => {
const planName = `${planDisplayName} Plan`;
const startFetcher = useTrialStartActionFetcher();
const invalidateAccountData = useInvalidateAccountData();
const { load: usageLoad, state: usageState, data: usageData } = useResourceUsageFetcher();
function handleStartTrial() {
@@ -66,8 +68,11 @@ export const HeaderPlanIndicator = ({ isMinimal }: Props) => {
useEffect(() => {
if (startFetcher.data?.success) {
setCanTrial(false);
// trial.start wrote the new plan to localStorage; refresh the query so every
// plan consumer (this indicator included) reflects the upgraded plan.
invalidateAccountData();
}
}, [startFetcher.data?.success]);
}, [startFetcher.data?.success, invalidateAccountData]);
const isUnlimited = isEnterpriseLike;
@@ -9,13 +9,13 @@ import * as reactUse from 'react-use';
import type { ScanResult } from '~/common/import';
import { importScannedResources } from '~/routes/import.resources';
import { scanImportResources } from '~/routes/import.scan';
import { useOrganizationLoaderData } from '~/routes/organization';
import { createProject } from '~/routes/organization.$organizationId.project.new';
import { Checkbox } from '~/ui/components/base/checkbox';
import { Modal, type ModalHandle } from '~/ui/components/base/modal';
import { ModalHeader } from '~/ui/components/base/modal-header';
import { Icon } from '~/ui/components/icon';
import { Button } from '~/ui/components/themed-button';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
import { showModal } from '..';
@@ -503,9 +503,9 @@ export const ImportProjectsModal = ({ organizationId, onHide }: { organizationId
modalRef.current?.show();
}, []);
const organizationData = useOrganizationLoaderData();
const organizations = useOrganizations();
const organizationName =
organizationData?.organizations.find(org => org.id === organizationId)?.name || 'Organization';
organizations.find(org => org.id === organizationId)?.name || 'Organization';
const [projectItems, setProjectItems] = useState<ProjectImportItem[]>([]);
const [processingUIStatus, setProcessingUiStatus] = useState<'loading' | 'importing' | 'error' | 'complete'>(
@@ -5,10 +5,10 @@ import { Button, Dialog, Heading, Input, Modal, ModalOverlay } from 'react-aria-
import { useRootLoaderData } from '~/root';
import { useResetVaultKeyFetcher } from '~/routes/auth.reset-vault-key';
import { useValidateVaultKeyActionFetcher } from '~/routes/auth.validate-vault-key';
import { useOrganizationLoaderData } from '~/routes/organization';
import { PromptButton } from '~/ui/components/base/prompt-button';
import { Icon } from '~/ui/components/icon';
import { VaultKeyDisplayInput } from '~/ui/components/settings/vault-key-panel';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
export interface InputVaultKeyModalProps {
onClose: (vaultKey?: string) => void;
@@ -23,7 +23,7 @@ export const InputVaultKeyModal = (props: InputVaultKeyModalProps) => {
const [resetDone, setResetDone] = useState(false);
const resetVaultKeyFetcher = useResetVaultKeyFetcher();
const validateVaultKeyFetcher = useValidateVaultKeyActionFetcher();
const { organizations } = useOrganizationLoaderData()!;
const organizations = useOrganizations();
const isLoading = resetVaultKeyFetcher.state !== 'idle' || validateVaultKeyFetcher.state !== 'idle';
useEffect(() => {
@@ -18,11 +18,11 @@ import { getAppWebsiteBaseURL } from '~/common/constants';
import { docsPricingLearnMoreLink } from '~/common/documentation';
import { debounce } from '~/common/misc';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useCollaboratorsSearchLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-search';
import { AnalyticsEvent } from '~/ui/analytics';
import { Icon } from '~/ui/components/icon';
import { useIsLightTheme } from '~/ui/hooks/theme';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { startInvite } from './encryption';
import { OrganizationMemberRolesSelector, SELECTOR_TYPE } from './organization-member-roles-selector';
@@ -122,8 +122,8 @@ export const InviteForm = ({
const [searchParams, setSearchParams] = useSearchParams();
const { userSession } = useRootLoaderData()!;
const organizationData = useOrganizationLoaderData();
const organization = organizationData?.organizations.find(o => o.id === organizationId);
const organizations = useOrganizations();
const organization = organizations.find(o => o.id === organizationId);
const isUserOwner = Boolean(organization?.is_owner);
const sessionId = userSession.id;
@@ -30,7 +30,6 @@ import { useParams, useSearchParams } from 'react-router';
import { getAppWebsiteBaseURL } from '~/common/constants';
import { debounce } from '~/common/misc';
import { invariant } from '~/common/utils/invariant';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useCollaboratorsFetcher } from '~/routes/organization.$organizationId.collaborators';
import { useInviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId';
import { useReinviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite';
@@ -42,6 +41,7 @@ import { PromptButton } from '~/ui/components/base/prompt-button';
import { Icon } from '~/ui/components/icon';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { showModal } from '~/ui/components/modals/index';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { InviteForm } from './invite-form';
import { OrganizationMemberRolesSelector, SELECTOR_TYPE } from './organization-member-roles-selector';
@@ -597,8 +597,8 @@ export const InviteModalContainer: FC<{
}> = ({ isOpen, setIsOpen }) => {
const [loadingOrgInfo, setLoadingOrgInfo] = useState(true);
const { organizationId } = useParams();
const organizationData = useOrganizationLoaderData();
const currentOrg = organizationData?.organizations.find(o => o.id === organizationId);
const organizations = useOrganizations();
const currentOrg = organizations.find(o => o.id === organizationId);
const [allRoles, setAllRoles] = useState<Role[]>([]);
const [currentUserRoleInOrg, setCurrentUserRoleInOrg] = useState<Role | null>(null);
const [orgFeatures, setOrgFeatures] = useState<FeatureList | null>(null);
@@ -5,8 +5,8 @@ import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } f
import { OverlayContainer } from 'react-aria';
import { href, useNavigate, useParams } from 'react-router';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useWorkspaceMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.move';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '../../../common/get-workspace-label';
@@ -26,7 +26,7 @@ export const WorkspaceDuplicateModal: FC<WorkspaceDuplicateModalProps> = ({ work
organizationId: string;
projectId: string;
};
const organizationData = useOrganizationLoaderData();
const organizations = useOrganizations();
const [selectedOrgId, setSelectedOrgId] = useState(organizationId);
const [projectOptions, setProjectOptions] = useState<BaseModel[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
@@ -115,7 +115,7 @@ export const WorkspaceDuplicateModal: FC<WorkspaceDuplicateModalProps> = ({ work
<label>
Organization:
<select name="orgId" value={selectedOrgId} onChange={e => setSelectedOrgId(e.target.value)}>
{organizationData?.organizations.map(({ id, name }) => (
{organizations.map(({ id, name }) => (
<option key={id} value={id}>
{name}
</option>
@@ -7,9 +7,9 @@ import { Button } from '~/basic-components/button';
import { LearnMoreLink } from '~/basic-components/link';
import { getAppWebsiteBaseURL } from '~/common/constants';
import { docsPricingLearnMoreLink } from '~/common/documentation';
import { useOrganizationLoaderData } from '~/routes/organization';
import type { ProjectType } from '~/ui/components/project/utils';
import { useIsLightTheme } from '~/ui/hooks/theme';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
interface Props {
isGitSyncEnabled: boolean;
@@ -20,9 +20,9 @@ export const ProjectTypeWarning = ({ isGitSyncEnabled, storageType, storageRules
const isLightTheme = useIsLightTheme();
const showStorageRestrictionMessage =
!storageRules.enableCloudSync || !storageRules.enableLocalVault || !storageRules.enableGitSync;
const organizationData = useOrganizationLoaderData();
const organizations = useOrganizations();
const { organizationId } = useParams() as { organizationId: string };
const organization = organizationData?.organizations.find(o => o.id === organizationId);
const organization = organizations.find(o => o.id === organizationId);
// TODO: extract to a hook later
const isUserOwner = Boolean(organization?.is_owner);
return (
@@ -19,7 +19,6 @@ import { Button, Heading, ListBox, ListBoxItem, Popover, Select, SelectValue } f
import { href, useParams } from 'react-router';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useProjectListWorkspacesLoaderFetcher } from '~/routes/organization.$organizationId.project.$projectId.list-workspaces';
import { useProjectMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move';
import { useProjectMoveWorkspaceActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move-workspace';
@@ -27,6 +26,7 @@ import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.pr
import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { ImportProjectsModal } from '~/ui/components/modals/import-modal/import-projects-modal';
import { useOrganizations } from '~/ui/hooks/use-account-server-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import { usePlanData } from '~/ui/hooks/use-plan';
@@ -633,8 +633,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
projectId: string;
workspaceId?: string;
};
const organizationData = useOrganizationLoaderData();
const organizations = organizationData?.organizations || [];
const organizations = useOrganizations();
const { features } = useOrganizationPermissions();
const { isEnterprisePlan } = usePlanData();
@@ -681,7 +680,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
const projectName = activeProject?.name ?? getProductName();
const projects = projectLoaderData?.projects || [];
const organizationName =
organizationData?.organizations.find(org => org.id === organizationId)?.name || 'Organization';
organizations.find(org => org.id === organizationId)?.name || 'Organization';
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isImportProjectsModalOpen, setIsImportProjectsModalOpen] = useState(false);
@@ -10,9 +10,9 @@ import { useProjectLoaderData } from '~/routes/organization.$organizationId.proj
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useInsomniaSyncDataActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data';
import { useOrganizationSyncProjectsActionFetcher } from '~/routes/organization.$organizationId.sync-projects';
import { useOrganizationSyncActionFetcher } from '~/routes/organization.sync';
import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus';
import { avatarImageCache } from '~/ui/hooks/image-cache';
import { useInvalidateAccountData } from '~/ui/hooks/use-account-server-data';
import { useInvalidateOrganizationStorageRule } from '~/ui/hooks/use-organization-storage-rule';
const InsomniaEventStreamContext = createContext<{
@@ -97,7 +97,7 @@ export const InsomniaEventStreamProvider: FC<PropsWithChildren> = ({ children })
const remoteId = projectData?.activeProject?.remoteId || workspaceData?.activeProject.remoteId;
const [presence, setPresence] = useState<UserPresence[]>([]);
const { submit: syncOrganizationsSubmit } = useOrganizationSyncActionFetcher();
const invalidateAccountData = useInvalidateAccountData();
const invalidateStorageRule = useInvalidateOrganizationStorageRule();
const { submit: syncProjectsSubmit } = useOrganizationSyncProjectsActionFetcher();
const { submit: syncDataSubmit } = useInsomniaSyncDataActionFetcher();
@@ -183,7 +183,7 @@ export const InsomniaEventStreamProvider: FC<PropsWithChildren> = ({ children })
if (event.avatar) {
window.setTimeout(() => avatarImageCache.invalidate(event.avatar), CDN_INVALIDATION_TTL);
}
syncOrganizationsSubmit();
invalidateAccountData();
} else if (event.type === 'StorageRuleChanged' && (event.team.startsWith('org_') || event.team.startsWith('team_'))) {
invalidateStorageRule(event.team);
} else if (event.type === 'TeamProjectChanged' && event.team === organizationId) {
@@ -249,7 +249,7 @@ export const InsomniaEventStreamProvider: FC<PropsWithChildren> = ({ children })
organizationId,
revalidate,
syncDataSubmit,
syncOrganizationsSubmit,
invalidateAccountData,
syncProjectsSubmit,
invalidateStorageRule,
userSession.accountId,
@@ -0,0 +1,86 @@
import type { CurrentPlan, Organization, User } from 'insomnia-api';
import { useCallback } from 'react';
import {
fetchOrganizationData,
type OrganizationData,
readOrganizationDataFromLocalStorage,
writeOrganizationDataToLocalStorage,
} from '~/common/organization';
import { useRootLoaderData } from '~/root';
import { useServerDataQueryClient } from '~/ui/context/app/server-data-context';
import { useServerQuery } from '~/ui/hooks/use-query';
// Account-scoped server data (organizations / user / current plan).
const accountServerDataKey = (accountId: string) => ['account-server-data', accountId] as const;
const loggedOutData: OrganizationData = { organizations: [], user: undefined, currentPlan: undefined };
// Stable selector references so TanStack doesn't re-run them every render.
const selectOrganizations = (data: OrganizationData) => data.organizations;
const selectUser = (data: OrganizationData) => data.user;
const selectCurrentPlan = (data: OrganizationData) => data.currentPlan;
/**
* The single source of truth (for React consumers) of the current account's organizations,
* user and current plan.
* Freshness: the server-data client's default `staleTime` refetches on mount, so the value is
* authoritative regardless of what localStorage held. The queryFn writes the result through to
* localStorage — a bridge that keeps the remaining non-React readers (`getInitialEntry`, various
* loaders, event-stream handlers) working while their migration is done in a later phase.
*
* Refresh is event-driven and owned by `useInvalidateAccountData` (SSE `OrganizationChanged`,
* trial start, etc.).
*
* Prefer the slice hooks below (`useOrganizations` / `useCurrentUser` / `useCurrentPlan`) at call
* sites; each subscribes to only its slice.
*/
function useAccountServerData<TData>(select: (data: OrganizationData) => TData): TData | undefined {
const { userSession } = useRootLoaderData()!;
const sessionId = userSession.id;
const accountId = userSession.accountId;
const isLoggedIn = !!sessionId;
const { data } = useServerQuery({
queryKey: accountServerDataKey(accountId),
queryFn: async () => {
const result = await fetchOrganizationData(sessionId);
writeOrganizationDataToLocalStorage(accountId, result);
return result;
},
// Seed from localStorage for an instant first paint (matching the old loader, which read
// localStorage synchronously). When logged out we intentionally keep `user`/`currentPlan`
// undefined so consumers can distinguish "no user".
initialData: () => (isLoggedIn ? readOrganizationDataFromLocalStorage(accountId) : loggedOutData),
enabled: isLoggedIn,
select,
});
return data;
}
/** The current account's organizations. Always an array. */
export function useOrganizations(): Organization[] {
return useAccountServerData(selectOrganizations) ?? [];
}
/** The signed-in user, or undefined when logged out / not yet loaded. */
export function useCurrentUser(): User | undefined {
return useAccountServerData(selectUser);
}
/** The account's current plan, or undefined when logged out / not yet loaded. */
export function useCurrentPlan(): CurrentPlan | undefined {
return useAccountServerData(selectCurrentPlan);
}
export function useInvalidateAccountData() {
const queryClient = useServerDataQueryClient();
const { userSession } = useRootLoaderData()!;
const accountId = userSession.accountId;
return useCallback(
() => queryClient.invalidateQueries({ queryKey: accountServerDataKey(accountId) }),
[queryClient, accountId],
);
}
+7 -11
View File
@@ -3,7 +3,7 @@ import { models } from 'insomnia-data';
import { useParams } from 'react-router';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useCurrentPlan, useOrganizations } from '~/ui/hooks/use-account-server-data';
export const usePlanData = () => {
let isOwner = false;
@@ -14,19 +14,15 @@ export const usePlanData = () => {
let isEnterprisePlan = false;
const { userSession } = useRootLoaderData()!;
const { organizationId } = useParams<{ organizationId: string }>();
const organizationData = useOrganizationLoaderData();
const organizations = useOrganizations();
const currentPlan = useCurrentPlan();
// ensure user has logged in with valid organization
if (
organizationData &&
userSession &&
Array.isArray(organizationData.organizations) &&
organizationData.organizations.length > 0
) {
const currentOrg = organizationData.organizations.find(organization => organization.id === organizationId);
if (userSession && Array.isArray(organizations) && organizations.length > 0) {
const currentOrg = organizations.find(organization => organization.id === organizationId);
if (currentOrg && userSession.accountId) {
isOwner = Boolean(currentOrg.is_owner);
}
planType = organizationData.currentPlan?.type || planType;
planType = currentPlan?.type || planType;
isFreePlan = planType.includes('free');
isTeamPlan = planType.includes('team');
isEnterprisePlan = planType.includes('enterprise');
@@ -34,7 +30,7 @@ export const usePlanData = () => {
}
return {
isOwner,
currentPlan: organizationData?.currentPlan,
currentPlan,
planDisplayName,
isFreePlan,
isTeamPlan,
@@ -1,8 +1,9 @@
import { useOrganizationLoaderData } from '~/routes/organization';
import { useCurrentPlan, useCurrentUser } from '~/ui/hooks/use-account-server-data';
import { diffInDayCeil } from '~/ui/utils';
export function useUserService() {
const { currentPlan, user } = useOrganizationLoaderData()!;
const currentPlan = useCurrentPlan();
const user = useCurrentUser();
const isPro = currentPlan?.type === 'individual' || currentPlan?.type === 'team';
const isEnterpriseOwner = currentPlan?.type === 'enterprise';
const isEnterpriseMember = currentPlan?.type === 'enterprise-member';
+1 -2
View File
@@ -7,7 +7,6 @@ import { href, matchPath, type PathMatch, useFetcher } from 'react-router';
import { CURRENT_MIGRATION_VERSION } from '~/sync/git/git-migration-version';
export const enum AsyncTask {
SyncOrganization,
MigrateProjects,
SyncProjects,
}
@@ -142,7 +141,7 @@ export const getInitialEntry = async () => {
pathname: await getInitialRouteForOrganization({ organizationId, navigateToWorkspace: true }),
state: {
// async task need to execute when first entry
asyncTaskList: [AsyncTask.SyncOrganization, AsyncTask.MigrateProjects, AsyncTask.SyncProjects],
asyncTaskList: [AsyncTask.MigrateProjects, AsyncTask.SyncProjects],
},
};
}