From 0633432aba4ad1bbcccd6b0e4113d3c50cefd4a9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:13:36 +0530 Subject: [PATCH 01/11] fix(admin): stop server tables issuing a duplicate request on mount Every server-mode DataTable fired two requests for its first page. INITIAL_QUERY carried no sort while defaultSort was passed as a prop. DataTable seeds its internal query from getDefaultTableQuery(defaultSort, query) and its mount effect emits unconditionally, since oldQueryRef starts null. The emitted query therefore differs from the one the parent already had in state by exactly the sort field. connect-query builds its cache key with createMessageKey, which omits unset fields, so sort: [] and sort: [{...}] hash to different keys. The key changed, a second request went out, and the first was aborted mid-flight once its observer was dropped. Seeding the initial sort makes the mount emit structurally identical to the query already in state, so the key is unchanged and no refetch is triggered. --- web/sdk/admin/views/audit-logs/index.tsx | 2 ++ web/sdk/admin/views/invoices/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/apis/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/invoices/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/members/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/pat/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/projects/index.tsx | 2 ++ web/sdk/admin/views/organizations/details/tokens/index.tsx | 2 ++ web/sdk/admin/views/organizations/list/index.tsx | 2 ++ web/sdk/admin/views/users/list/list.tsx | 2 ++ 10 files changed, 20 insertions(+) diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 09a1d788df..3e8ffbe0b1 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: { diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index e6ac9797b7..ad01ef81e9 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 = { diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 93ff54e4a7..d7cb99f723 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: { diff --git a/web/sdk/admin/views/organizations/details/invoices/index.tsx b/web/sdk/admin/views/organizations/details/invoices/index.tsx index 2d4d76da3d..ede48b6b73 100644 --- a/web/sdk/admin/views/organizations/details/invoices/index.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/index.tsx @@ -22,6 +22,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/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index ef798235dd..499dcd2a73 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -31,6 +31,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', 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/details/pat/index.tsx b/web/sdk/admin/views/organizations/details/pat/index.tsx index 6c323298bb..27a04b2156 100644 --- a/web/sdk/admin/views/organizations/details/pat/index.tsx +++ b/web/sdk/admin/views/organizations/details/pat/index.tsx @@ -26,6 +26,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/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index c1d46db6d0..59a12bd721 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -28,6 +28,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/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..b8a0887c1f 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 = { diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index 7c9fd2d6b1..d5c3141fad 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 { From 85174f4404615f93a9ff8d987de44654db84fe3b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:13:50 +0530 Subject: [PATCH 02/11] fix(admin): drop the empty default sort on project members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project members dialog passed defaultSort={{ name: "", order: "desc" }}, which sent an RQL sort with an empty field name on every mount and guaranteed the key change that caused a duplicate request. The sort was never applied: ProjectUsersRepository.prepareDataQuery builds its statement from search, offset and limit only, and ignores sort entirely. No column in this table is sortable either — title sets enableSorting: false and the rest are unsorted. Removing the prop leaves both the initial and emitted query at sort: [], so ordering is unchanged and the mount no longer refetches. --- .../admin/views/organizations/details/projects/members/index.tsx | 1 - 1 file changed, 1 deletion(-) 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..6684396bfd 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -217,7 +217,6 @@ export const ProjectMembersDialog = ({ data={data} isLoading={isLoading} mode="server" - defaultSort={{ name: "", order: "desc" }} onTableQueryChange={onTableQueryChange} onLoadMore={handleLoadMore} > From 35ec7794bacd526db98fdd9828e337aaf7ec178b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:14:01 +0530 Subject: [PATCH 03/11] fix(admin): keep the org detail tab mounted while billing loads The layout renders a spinner in place of its children while isLoading is true, and isLoading included isBillingAccountLoading. That query is gated on firstBillingAccountId, which arrives from a separate listBillingAccounts call that was not itself in the gate. A disabled query reports isLoading false, so once the org and role queries settled the gate opened, the tab mounted and its tables fetched. When listBillingAccounts then resolved, the billing query enabled, isLoading went true again and the whole tab unmounted, only to remount and refetch once billing settled. Gating only on queries that are enabled from the first render makes the transition monotonic, so the tab mounts once. The side panel already renders its own skeletons while billing resolves. --- web/sdk/admin/views/organizations/details/index.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 7f4d680a96..f2f2283079 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -258,11 +258,16 @@ export const OrganizationDetailsView = ({ tokenBalanceError, ]); + /* + * Gate only on queries that are enabled from the first render, so it can + * flip true -> false exactly once. + * - billing is deliberately excluded: it waits on an id from + * listBillingAccounts, so it re-enters loading *after* the gate opened, + * which unmounted and remounted the whole tab mid-load + * - the side panel renders its own skeletons while billing resolves + */ const isLoading = - isOrganizationLoading || - isDefaultRolesLoading || - isOrgRolesLoading || - isBillingAccountLoading; + isOrganizationLoading || isDefaultRolesLoading || isOrgRolesLoading; return ( Date: Tue, 11 Aug 2026 04:17:11 +0530 Subject: [PATCH 04/11] fix(admin): stop fast scrolling firing redundant page requests VirtualizedContent calls loadMoreData() from its scroll handler, guarded only by the isLoading value captured in that render. Scroll events fire per frame, while isFetchingNextPage only becomes true after react-query notifies and React re-renders, so several events can pass the guard for the same page. fetchNextPage defaults to cancelRefetch: true, so each of those calls aborts and restarts the previous one: three calls in a frame issue three requests and advance by a single page. Guard on hasNextPage and isFetchingNextPage at the call site, matching what the members table already does. --- web/sdk/admin/views/audit-logs/index.tsx | 8 +++++++- web/sdk/admin/views/organizations/list/index.tsx | 8 ++++++++ web/sdk/admin/views/users/list/list.tsx | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 3e8ffbe0b1..687221a6f5 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -142,9 +142,15 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi [queryClient], ); + /* + * The scroll handler fires per frame while isFetchingNextPage is still + * catching up, and fetchNextPage cancels the in-flight page by default — + * so without this guard a fast scroll sends several aborted requests to + * load a single page. + */ 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/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index b8a0887c1f..da5aa50d1f 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -120,6 +120,7 @@ export const OrganizationListView = ({ isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -166,7 +167,14 @@ export const OrganizationListView = ({ }); }; + /* + * The scroll handler fires per frame while isFetchingNextPage is still + * catching up, and fetchNextPage cancels the in-flight page by default — + * so without this guard a fast scroll sends several aborted requests to + * load a single page. + */ const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { await fetchNextPage(); } catch (error) { diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index d5c3141fad..a5a28f2572 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -65,6 +65,7 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -95,7 +96,14 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = }); }; + /* + * The scroll handler fires per frame while isFetchingNextPage is still + * catching up, and fetchNextPage cancels the in-flight page by default — + * so without this guard a fast scroll sends several aborted requests to + * load a single page. + */ const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage) return; try { await fetchNextPage(); } catch (error) { From c8db582a9f02f6bd6b3817c9b21c3ca947c079e3 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:18:27 +0530 Subject: [PATCH 05/11] fix(admin): scope the members invalidation to its organization The invalidation key was built with an empty input. react-query matches query keys partially, and an empty object matches vacuously, so every cached searchOrganizationUsers entry was invalidated regardless of which org it belonged to. Updating a role in one org refetched the member list of every other org still held in cache. Keying on the org id scopes the match to that org, while leaving `query` unset so its filter and sort variants are still covered. --- .../admin/views/organizations/details/members/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index 499dcd2a73..c905a98cc5 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -193,11 +193,16 @@ export function OrganizationMembersView() { }); async function invalidateMembersQuery() { + /* + * Keyed on the org only: an empty input matches partially, so it would + * invalidate every org's cached member list. Leaving `query` out still + * covers this org's filter and sort variants. + */ await queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: AdminServiceQueries.searchOrganizationUsers, transport, - input: {}, + input: { id: organizationId }, cardinality: "infinite", }), }); From 75bc15cbbedb8ffd4f4b1fa41fac1ffd6cc71a3a Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:19:45 +0530 Subject: [PATCH 06/11] fix(admin): give queries a default staleTime The QueryClient set only retry and refetchOnWindowFocus, leaving staleTime at its default of 0. Combined with refetchOnMount, every mount of every component refetched, so reference data such as roles, plans and products was re-requested on each navigation. Four views had worked around this locally with staleTime: Infinity, which left the same key refetching or not depending on which page it was reached from. A 30s default covers navigation without holding data long enough to look stale. Mutations invalidate their own keys and the two panels that need immediate freshness call refetch(), which ignores staleTime, so writes are still reflected at once. The search-backed tables keep their explicit staleTime: 0. --- web/apps/admin/src/contexts/ConnectProvider.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/apps/admin/src/contexts/ConnectProvider.tsx b/web/apps/admin/src/contexts/ConnectProvider.tsx index 11393824e9..0ffa717383 100644 --- a/web/apps/admin/src/contexts/ConnectProvider.tsx +++ b/web/apps/admin/src/contexts/ConnectProvider.tsx @@ -4,12 +4,20 @@ import type { ReactNode } from "react"; import { TransportProvider } from "@connectrpc/connect-query"; import { jsonTransport as transport } from "~/connect/transport"; -// Create a QueryClient instance +/* + * staleTime defaults to 0, which combined with refetchOnMount means every + * mount of every component refetches — reference data like roles, plans and + * products was re-requested on each navigation. A short window covers + * navigating between pages without holding data long enough to look stale; + * mutations invalidate their own keys, so writes are still reflected at once. + * The search-backed tables opt out with an explicit staleTime: 0. + */ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, + staleTime: 30 * 1000, }, }, }); From c101560f16b96b9cd5b5369eb6c2c1334d509c8a Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:22:00 +0530 Subject: [PATCH 07/11] fix(admin): reuse the resolved org instead of refetching it by id Cold-loading an org from a slug URL fetched the same organization twice. The page resolves the URL segment with getOrganization, and the view then fetches by id: connect-query keys on the request message, so the slug and the id are different keys and both went to the server. In-app navigation was unaffected because it carries the id in router state and skips the resolve, so this only hit deep links and refreshes. Seed the id-keyed entry with the org already resolved. This is done during render rather than in an effect: the view mounts in the same commit and child effects run first, so an effect would seed the cache after the request had already gone out. Depends on a non-zero default staleTime; with staleTime 0 the seeded entry is immediately stale and the view refetches regardless. --- .../src/pages/organizations/details/index.tsx | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index 21ea5afcf5..71b32f50ce 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; + /* + * Hand the resolved org to the view instead of letting it fetch again. + * Resolving from a slug keys the cache by that slug, while the view asks + * by id — two keys, same org, two requests. Seeded during render because + * the view mounts in this same commit and its effects run before ours. + */ + 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 From c0a73c51a9684b2ff7a739ef6be1cd9f2811e3ac Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:24:36 +0530 Subject: [PATCH 08/11] fix(admin): fetch the org member map only where it is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The details context fetched listOrganizationUsers — the full, unpaginated member list — for every organization page, on every tab. The result was only ever read by the projects tab: its columns render project member avatars from it, and the add-members dropdown filters against it. Move it behind a useOrgMembersMap hook called by those two consumers. react-query dedupes the request between them, so the projects tab still issues one, and the members, tokens, API, security, invoices and PAT tabs no longer issue it at all. The select is defined at module scope so its identity is stable and react-query can memoize the derived map instead of rebuilding it on every render. --- web/sdk/admin/hooks/useOrgMembersMap.ts | 32 +++++++++++++++++++ .../details/contexts/organization-context.tsx | 5 --- .../views/organizations/details/index.tsx | 31 ------------------ .../organizations/details/projects/index.tsx | 8 +++-- .../projects/use-add-project-members.tsx | 4 ++- 5 files changed, 41 insertions(+), 39 deletions(-) create mode 100644 web/sdk/admin/hooks/useOrgMembersMap.ts diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts new file mode 100644 index 0000000000..518dfcf699 --- /dev/null +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -0,0 +1,32 @@ +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. + * + * This is the full, unpaginated member list, so it is fetched by the views + * that need it rather than for every organization page. react-query dedupes + * the request between callers sharing an org id. + * + * Pass `undefined`/empty to disable the query. + */ +export const useOrgMembersMap = (orgId?: string) => + useQuery( + FrontierServiceQueries.listOrganizationUsers, + { id: orgId || "" }, + { + enabled: !!orgId, + select: toMembersMap, + }, + ); 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 f2f2283079..848de53300 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,7 +224,6 @@ export const OrganizationDetailsView = ({ kycError, defaultRolesError, orgRolesError, - orgMembersError, billingAccountsError, billingAccountError, tokenBalanceError, @@ -281,8 +252,6 @@ export const OrganizationDetailsView = ({ tokenBalance, isTokenBalanceLoading, fetchTokenBalance, - orgMembersMap, - isOrgMembersMapLoading, updateKYCDetails, kycDetails, isKYCLoading, diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index 59a12bd721..c5ef5df251 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -23,6 +23,7 @@ import { import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { useDebouncedValue } from '~hooks'; import { useTerminology } from "~/admin/hooks/useTerminology"; +import { useOrgMembersMap } from "~/admin/hooks/useOrgMembersMap"; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { @@ -85,8 +86,11 @@ const ErrorState = () => { 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/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( From 7a9491e5dac4d25f414d31d1b387b5bf5d424cd7 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 13:30:42 +0530 Subject: [PATCH 09/11] fix(admin): fetch invite dialog options only when it opens The invite trigger lives in the users page navbar, so the dialog component mounts with the page. Neither of the queries backing its fields was gated, so searchOrganizations and listRoles ran on every visit to the users list whether or not anyone opened the dialog. Gate both on the dialog's open state, as the PAT details dialog already does. --- web/sdk/admin/views/users/list/invite-users.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/sdk/admin/views/users/list/invite-users.tsx b/web/sdk/admin/views/users/list/invite-users.tsx index 5075a3e988..b031b5ae04 100644 --- a/web/sdk/admin/views/users/list/invite-users.tsx +++ b/web/sdk/admin/views/users/list/invite-users.tsx @@ -55,6 +55,11 @@ export const InviteUser = () => { const t = useTerminology(); const [open, onOpenChange] = useState(false); + /* + * Both lists only feed the dialog's fields, but the trigger lives in the + * navbar, so without a gate they were fetched on every visit to the users + * page whether or not anyone opened the dialog. + */ const { data: organizations, isLoading: isOrganizationsLoading, @@ -63,6 +68,7 @@ export const InviteUser = () => { AdminServiceQueries.searchOrganizations, create(SearchOrganizationsRequestSchema, { query: {} }), { + enabled: open, select: (data) => data?.organizations || [], } ); @@ -75,6 +81,7 @@ export const InviteUser = () => { FrontierServiceQueries.listRoles, create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), { + enabled: open, select: (data) => data?.roles || [], } ); From e39a065ff53c5313470deb639fcdc74298185c05 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 13:38:54 +0530 Subject: [PATCH 10/11] fix(admin): guard the last three load-more handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier guard covered the tables rendering VirtualizedContent, whose scroll handler fires per frame. These three only checked hasNextPage, so a second call could still land while the previous page was in flight — and fetchNextPage cancels the in-flight page by default, turning that into an aborted request for no gain. All 11 server tables now check both hasNextPage and isFetchingNextPage before paging. --- web/sdk/admin/views/invoices/index.tsx | 2 +- web/sdk/admin/views/organizations/details/apis/index.tsx | 2 +- .../views/organizations/details/projects/members/index.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index ad01ef81e9..c15c581e02 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -92,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 d7cb99f723..70372a5384 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -151,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/projects/members/index.tsx b/web/sdk/admin/views/organizations/details/projects/members/index.tsx index 6684396bfd..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(); From cec34fa6035f31ebfd7d426a6149192a4f79b5d1 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 14:21:53 +0530 Subject: [PATCH 11/11] docs(admin): tighten the comments added in this branch Cut each block back to the non-obvious point, and drop the load-more comment: it was repeated verbatim in three files and the guard reads clearly without it. --- web/apps/admin/src/contexts/ConnectProvider.tsx | 9 +++------ .../admin/src/pages/organizations/details/index.tsx | 8 ++++---- web/sdk/admin/hooks/useOrgMembersMap.ts | 10 +++------- web/sdk/admin/views/audit-logs/index.tsx | 6 ------ web/sdk/admin/views/organizations/details/index.tsx | 10 ++++------ .../views/organizations/details/members/index.tsx | 5 ++--- web/sdk/admin/views/organizations/list/index.tsx | 6 ------ web/sdk/admin/views/users/list/invite-users.tsx | 5 ++--- web/sdk/admin/views/users/list/list.tsx | 6 ------ 9 files changed, 18 insertions(+), 47 deletions(-) diff --git a/web/apps/admin/src/contexts/ConnectProvider.tsx b/web/apps/admin/src/contexts/ConnectProvider.tsx index 0ffa717383..1ea1f0bf51 100644 --- a/web/apps/admin/src/contexts/ConnectProvider.tsx +++ b/web/apps/admin/src/contexts/ConnectProvider.tsx @@ -5,12 +5,9 @@ import { TransportProvider } from "@connectrpc/connect-query"; import { jsonTransport as transport } from "~/connect/transport"; /* - * staleTime defaults to 0, which combined with refetchOnMount means every - * mount of every component refetches — reference data like roles, plans and - * products was re-requested on each navigation. A short window covers - * navigating between pages without holding data long enough to look stale; - * mutations invalidate their own keys, so writes are still reflected at once. - * The search-backed tables opt out with an explicit staleTime: 0. + * 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: { diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index 71b32f50ce..b5feb70a5e 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -85,10 +85,10 @@ export default function OrganizationDetailsPage() { const notFound = needsResolve && isSuccess && !org?.id; /* - * Hand the resolved org to the view instead of letting it fetch again. - * Resolving from a slug keys the cache by that slug, while the view asks - * by id — two keys, same org, two requests. Seeded during render because - * the view mounts in this same commit and its effects run before ours. + * 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) { diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts index 518dfcf699..8ec0b74a97 100644 --- a/web/sdk/admin/hooks/useOrgMembersMap.ts +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -13,13 +13,9 @@ const toMembersMap = (data?: ListOrganizationUsersResponse) => ); /** - * The organization's members keyed by id. - * - * This is the full, unpaginated member list, so it is fetched by the views - * that need it rather than for every organization page. react-query dedupes - * the request between callers sharing an org id. - * - * Pass `undefined`/empty to disable the query. + * 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( diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 687221a6f5..351113dc31 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -142,12 +142,6 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi [queryClient], ); - /* - * The scroll handler fires per frame while isFetchingNextPage is still - * catching up, and fetchNextPage cancels the in-flight page by default — - * so without this guard a fast scroll sends several aborted requests to - * load a single page. - */ const handleLoadMore = async () => { if (!hasNextPage || isFetchingNextPage) return; try { diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 848de53300..5b46770e30 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -230,12 +230,10 @@ export const OrganizationDetailsView = ({ ]); /* - * Gate only on queries that are enabled from the first render, so it can - * flip true -> false exactly once. - * - billing is deliberately excluded: it waits on an id from - * listBillingAccounts, so it re-enters loading *after* the gate opened, - * which unmounted and remounted the whole tab mid-load - * - the side panel renders its own skeletons while billing resolves + * 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; diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index c905a98cc5..e48601583c 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -194,9 +194,8 @@ export function OrganizationMembersView() { async function invalidateMembersQuery() { /* - * Keyed on the org only: an empty input matches partially, so it would - * invalidate every org's cached member list. Leaving `query` out still - * covers this org's filter and sort variants. + * Keyed on the org: keys match partially, so an empty input would + * invalidate every org. Omitting `query` still covers this org's variants. */ await queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index da5aa50d1f..42f4f25b36 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -167,12 +167,6 @@ export const OrganizationListView = ({ }); }; - /* - * The scroll handler fires per frame while isFetchingNextPage is still - * catching up, and fetchNextPage cancels the in-flight page by default — - * so without this guard a fast scroll sends several aborted requests to - * load a single page. - */ const handleLoadMore = async () => { if (!hasNextPage || isFetchingNextPage) return; try { diff --git a/web/sdk/admin/views/users/list/invite-users.tsx b/web/sdk/admin/views/users/list/invite-users.tsx index b031b5ae04..493802bd0c 100644 --- a/web/sdk/admin/views/users/list/invite-users.tsx +++ b/web/sdk/admin/views/users/list/invite-users.tsx @@ -56,9 +56,8 @@ export const InviteUser = () => { const [open, onOpenChange] = useState(false); /* - * Both lists only feed the dialog's fields, but the trigger lives in the - * navbar, so without a gate they were fetched on every visit to the users - * page whether or not anyone opened the dialog. + * 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, diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index a5a28f2572..36b2b5f9d3 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -96,12 +96,6 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = }); }; - /* - * The scroll handler fires per frame while isFetchingNextPage is still - * catching up, and fetchNextPage cancels the in-flight page by default — - * so without this guard a fast scroll sends several aborted requests to - * load a single page. - */ const handleLoadMore = async () => { if (!hasNextPage || isFetchingNextPage) return; try {