diff --git a/web/apps/admin/src/contexts/ConnectProvider.tsx b/web/apps/admin/src/contexts/ConnectProvider.tsx index 11393824e9..1ea1f0bf51 100644 --- a/web/apps/admin/src/contexts/ConnectProvider.tsx +++ b/web/apps/admin/src/contexts/ConnectProvider.tsx @@ -4,12 +4,17 @@ import type { ReactNode } from "react"; import { TransportProvider } from "@connectrpc/connect-query"; import { jsonTransport as transport } from "~/connect/transport"; -// Create a QueryClient instance +/* + * staleTime 0 + refetchOnMount refetches on every mount, so navigating + * re-requested roles, plans and products each time. Mutations invalidate their + * own keys, and the search tables opt out with an explicit staleTime: 0. + */ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, + staleTime: 30 * 1000, }, }, }); diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index 21ea5afcf5..b5feb70a5e 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -1,8 +1,13 @@ import { OrganizationDetailsView, useAdminPaths } from '@raystack/frontier/admin'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams, Outlet, Navigate } from 'react-router-dom'; -import { useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { createConnectQueryKey, useQuery, useTransport } from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + GetOrganizationResponseSchema, +} from '@raystack/proton/frontier'; import { AppContext } from '~/contexts/App'; import { clients } from '~/connect/clients'; import { exportCsvFromStream } from '~/utils/helper'; @@ -33,6 +38,8 @@ export default function OrganizationDetailsPage() { const paths = useAdminPaths(); const { config } = useContext(AppContext); const [countries, setCountries] = useState([]); + const queryClient = useQueryClient(); + const transport = useTransport(); const incomingOrgId = (location.state as { orgId?: string } | null)?.orgId; @@ -77,6 +84,26 @@ export default function OrganizationDetailsPage() { const orgId = stateOrgId || (paramIsId ? urlParam : org?.id); const notFound = needsResolve && isSuccess && !org?.id; + /* + * The view fetches by id; resolving from a slug keys the cache by the slug. + * Seed the id key so it doesn't refetch the org we already have. During + * render, not in an effect: the view mounts in this commit and its effects + * run first. + */ + const primedOrgId = useRef(undefined); + if (org?.id && primedOrgId.current !== org.id) { + primedOrgId.current = org.id; + queryClient.setQueryData( + createConnectQueryKey({ + schema: FrontierServiceQueries.getOrganization, + transport, + input: { id: org.id }, + cardinality: 'finite', + }), + create(GetOrganizationResponseSchema, { organization: org }), + ); + } + /* * Old UUID bookmark → canonical slug URL: * - one live URL per org; replace keeps the back-button sane diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts new file mode 100644 index 0000000000..8ec0b74a97 --- /dev/null +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@connectrpc/connect-query"; +import { FrontierServiceQueries, type User } from "@raystack/proton/frontier"; +import type { ListOrganizationUsersResponse } from "@raystack/proton/frontier"; + +/* Module scope keeps the identity stable, so react-query can memoize it. */ +const toMembersMap = (data?: ListOrganizationUsersResponse) => + (data?.users || []).reduce( + (acc, user) => { + acc[user.id || ""] = user; + return acc; + }, + {} as Record, + ); + +/** + * The organization's members keyed by id — the full, unpaginated list, so it + * is fetched by the views that need it rather than for every org page. + * react-query dedupes it between callers. Pass empty to disable. + */ +export const useOrgMembersMap = (orgId?: string) => + useQuery( + FrontierServiceQueries.listOrganizationUsers, + { id: orgId || "" }, + { + enabled: !!orgId, + select: toMembersMap, + }, + ); diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 09a1d788df..351113dc31 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -48,6 +48,8 @@ const DEFAULT_SORT: DataTableSort = { name: "occurredAt", order: "desc" }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -141,8 +143,8 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi ); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more audit logs:", error); diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index e6ac9797b7..c15c581e02 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -40,6 +40,8 @@ const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; export type InvoicesViewProps = { @@ -90,8 +92,8 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) { }; const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more invoices:", error); diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 93ff54e4a7..70372a5384 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -69,6 +69,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -149,8 +151,8 @@ export function OrganizationApisView() { }; const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more service users:", error); diff --git a/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx b/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx index d4f0854af1..9ab6fe88b2 100644 --- a/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx +++ b/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx @@ -3,7 +3,6 @@ import { OrganizationSchema, type Role, type BillingAccount, - type User, type OrganizationKyc, type BillingAccountDetails, } from "@raystack/proton/frontier"; @@ -29,8 +28,6 @@ interface OrganizationContextType { tokenBalance: string; isTokenBalanceLoading: boolean; fetchTokenBalance: () => void; - orgMembersMap: Record; - isOrgMembersMapLoading: boolean; updateKYCDetails: (kycDetails: OrganizationKyc | undefined) => void; kycDetails?: OrganizationKyc; isKYCLoading: boolean; @@ -55,8 +52,6 @@ const defaultOrganiztionContextValue = { query: "", onChange: () => {}, }, - orgMembersMap: {}, - isOrgMembersMapLoading: false, updateKYCDetails: () => {}, kycDetails: undefined, isKYCLoading: false, diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 7f4d680a96..5b46770e30 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -16,7 +16,6 @@ import { GetBillingBalanceRequestSchema, GetOrganizationKycResponseSchema, type Organization, - type User, } from "@raystack/proton/frontier"; export type OrganizationDetailsViewProps = { @@ -142,30 +141,6 @@ export const OrganizationDetailsView = ({ const roles = [...defaultRoles, ...organizationRoles]; - // Fetch organization members - const { - data: orgMembersMap = {}, - isLoading: isOrgMembersMapLoading, - error: orgMembersError, - } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - { id: organizationId || "" }, - { - enabled: !!organizationId, - select: (data) => { - const users = data?.users || []; - return users.reduce( - (acc, user) => { - const id = user.id || ""; - acc[id] = user; - return acc; - }, - {} as Record, - ); - }, - }, - ); - // Fetch billing accounts list const { data: firstBillingAccountId = "", error: billingAccountsError } = useQuery( @@ -232,9 +207,6 @@ export const OrganizationDetailsView = ({ if (orgRolesError) { console.error("Failed to fetch organization roles:", orgRolesError); } - if (orgMembersError) { - console.error("Failed to fetch organization members:", orgMembersError); - } if (billingAccountsError) { console.error("Failed to fetch billing accounts:", billingAccountsError); } @@ -252,17 +224,19 @@ export const OrganizationDetailsView = ({ kycError, defaultRolesError, orgRolesError, - orgMembersError, billingAccountsError, billingAccountError, tokenBalanceError, ]); + /* + * Only queries enabled from the first render, so the gate flips once: + * - billing waits on an id from listBillingAccounts, so it re-entered + * loading after the gate opened and remounted the tab mid-load + * - the side panel renders its own skeletons meanwhile + */ const isLoading = - isOrganizationLoading || - isDefaultRolesLoading || - isOrgRolesLoading || - isBillingAccountLoading; + isOrganizationLoading || isDefaultRolesLoading || isOrgRolesLoading; return ( { export function OrganizationProjectsView() { const t = useTerminology(); - const { organization, search, orgMembersMap, isOrgMembersMapLoading } = - useContext(OrganizationContext); + const { organization, search } = useContext(OrganizationContext); + const { + data: orgMembersMap = {}, + isLoading: isOrgMembersMapLoading, + } = useOrgMembersMap(organization?.id); const { onChange: onSearchChange, setVisibility: setSearchVisibility, diff --git a/web/sdk/admin/views/organizations/details/projects/members/index.tsx b/web/sdk/admin/views/organizations/details/projects/members/index.tsx index 8e40fcd8e4..c0027a9985 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -140,13 +140,13 @@ export const ProjectMembersDialog = ({ }, []); const handleLoadMore = useCallback(async () => { + if (!hasNextPage || isFetchingNextPage) return; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more project members:", error); } - }, [hasNextPage, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); async function refetchMembers() { await refetch(); @@ -217,7 +217,6 @@ export const ProjectMembersDialog = ({ data={data} isLoading={isLoading} mode="server" - defaultSort={{ name: "", order: "desc" }} onTableQueryChange={onTableQueryChange} onLoadMore={handleLoadMore} > diff --git a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx index 40e101ebe7..f93ffd9d65 100644 --- a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx +++ b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx @@ -7,6 +7,7 @@ import { FrontierServiceQueries, ListProjectUsersRequestSchema, ListRolesRequest import { create } from "@bufbuild/protobuf"; import { handleConnectError } from "~/utils/error"; import { useTerminology } from "../../../../hooks/useTerminology"; +import { useOrgMembersMap } from "../../../../hooks/useOrgMembersMap"; interface useAddProjectMembersProps { projectId: string; @@ -15,7 +16,8 @@ interface useAddProjectMembersProps { export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { const t = useTerminology(); const memberLabel = t.member({ case: "capital" }); - const { orgMembersMap } = useContext(OrganizationContext); + const { organization } = useContext(OrganizationContext); + const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id); const [searchQuery, setSearchQuery] = useState(""); const { data: projectMembers, isLoading, refetch } = useQuery( diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index cd7fb8af7a..b43cbab333 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -18,6 +18,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index 516bfe87a5..42f4f25b36 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -68,6 +68,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; export type OrganizationListViewProps = { @@ -118,6 +120,7 @@ export const OrganizationListView = ({ isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -165,6 +168,7 @@ export const OrganizationListView = ({ }; const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { await fetchNextPage(); } catch (error) { diff --git a/web/sdk/admin/views/users/list/invite-users.tsx b/web/sdk/admin/views/users/list/invite-users.tsx index 5075a3e988..493802bd0c 100644 --- a/web/sdk/admin/views/users/list/invite-users.tsx +++ b/web/sdk/admin/views/users/list/invite-users.tsx @@ -55,6 +55,10 @@ export const InviteUser = () => { const t = useTerminology(); const [open, onOpenChange] = useState(false); + /* + * These only feed the dialog's fields, but its trigger lives in the navbar — + * ungated they were fetched on every visit to the users page. + */ const { data: organizations, isLoading: isOrganizationsLoading, @@ -63,6 +67,7 @@ export const InviteUser = () => { AdminServiceQueries.searchOrganizations, create(SearchOrganizationsRequestSchema, { query: {} }), { + enabled: open, select: (data) => data?.organizations || [], } ); @@ -75,6 +80,7 @@ export const InviteUser = () => { FrontierServiceQueries.listRoles, create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), { + enabled: open, select: (data) => data?.roles || [], } ); diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index 7c9fd2d6b1..36b2b5f9d3 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -36,6 +36,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Seeded so DataTable's mount emit matches this, instead of forcing a refetch. + sort: [DEFAULT_SORT], }; interface UsersListProps { @@ -63,6 +65,7 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -94,6 +97,7 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = }; const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { await fetchNextPage(); } catch (error) {