From 0633432aba4ad1bbcccd6b0e4113d3c50cefd4a9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 04:13:36 +0530 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 { From b5d4a93e1308c9709cb004e7387c5608132becec Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 17 Aug 2026 20:53:24 +0530 Subject: [PATCH 12/15] fix(admin): latch load-more against scroll bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard read hasNextPage and isFetchingNextPage, but both are last-render values and react-query notifies its observers on a macrotask. VirtualizedContent calls onLoadMore straight from onScroll, so a burst of scroll events clears the guard several times before React re-renders. fetchNextPage defaults to cancelRefetch: true, so each of those extra calls aborted the in-flight page and re-issued it — exactly the redundant requests the guard was meant to prevent. Add a ref that flips synchronously, so only the first call in a burst gets through. All 11 server tables now share the shape, and the four that had the nested conditional were inverted to the same early return. --- web/sdk/admin/views/audit-logs/index.tsx | 11 ++++++++--- web/sdk/admin/views/invoices/index.tsx | 11 ++++++++--- .../views/organizations/details/apis/index.tsx | 11 ++++++++--- .../organizations/details/invoices/index.tsx | 12 +++++++++--- .../organizations/details/members/index.tsx | 17 ++++++++++------- .../views/organizations/details/pat/index.tsx | 12 +++++++++--- .../organizations/details/projects/index.tsx | 12 +++++++++--- .../details/projects/members/index.tsx | 9 +++++++-- .../organizations/details/tokens/index.tsx | 12 +++++++++--- .../admin/views/organizations/list/index.tsx | 11 ++++++++--- web/sdk/admin/views/users/list/list.tsx | 10 ++++++++-- 11 files changed, 93 insertions(+), 35 deletions(-) diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 351113dc31..7d00fec1d4 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -6,7 +6,7 @@ import { Flex, } from "@raystack/apsara"; import { useDebouncedState } from "@raystack/apsara/hooks"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Navbar from "./navbar"; import styles from "./audit-logs.module.css"; import { getColumns } from "./columns"; @@ -48,7 +48,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -142,12 +142,17 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi [queryClient], ); + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more audit logs:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index c15c581e02..d73655648a 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -5,7 +5,7 @@ import { EmptyState, Flex, } from "@raystack/apsara"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { PageTitle } from "../../components/PageTitle"; import { InvoicesNavabar } from "./navbar"; import styles from "./invoices.module.css"; @@ -40,7 +40,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; @@ -91,12 +91,17 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) { }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more invoices:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 70372a5384..8cf71eaa35 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -5,7 +5,7 @@ import { CodeIcon, ExclamationTriangleIcon, } from "@radix-ui/react-icons"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { getColumns } from "./columns"; @@ -69,7 +69,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -150,12 +150,17 @@ export function OrganizationApisView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more service users:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/invoices/index.tsx b/web/sdk/admin/views/organizations/details/invoices/index.tsx index ede48b6b73..ed6c8cf0ce 100644 --- a/web/sdk/admin/views/organizations/details/invoices/index.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/index.tsx @@ -3,7 +3,7 @@ import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import styles from "./invoices.module.css"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { BanknotesIcon } from "~/admin/assets/icons/BanknotesIcon"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { getColumns } from "./columns"; @@ -22,7 +22,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -174,9 +174,15 @@ export function OrganizationInvoicesView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index e48601583c..1a7cf7fe2a 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -2,7 +2,7 @@ import { AlertDialog, DataTable, EmptyState, Flex } from "@raystack/apsara"; import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import { PageTitle } from "~/admin/components/PageTitle"; import styles from "./members.module.css"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { getColumns } from "./columns"; import type { SearchOrganizationUsersResponse_OrganizationUser } from "@raystack/proton/frontier"; import { AdminServiceQueries } from "@raystack/proton/frontier"; @@ -31,7 +31,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -161,9 +161,15 @@ export function OrganizationMembersView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; @@ -193,10 +199,7 @@ export function OrganizationMembersView() { }); async function invalidateMembersQuery() { - /* - * Keyed on the org: keys match partially, so an empty input would - * invalidate every org. Omitting `query` still covers this org's variants. - */ + // Keys match partially: {} would hit every org; omitting query is deliberate. await queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: AdminServiceQueries.searchOrganizationUsers, diff --git a/web/sdk/admin/views/organizations/details/pat/index.tsx b/web/sdk/admin/views/organizations/details/pat/index.tsx index 27a04b2156..870d2d2440 100644 --- a/web/sdk/admin/views/organizations/details/pat/index.tsx +++ b/web/sdk/admin/views/organizations/details/pat/index.tsx @@ -1,7 +1,7 @@ import { DataTable, EmptyState, Flex } from "@raystack/apsara"; import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import { LockClosedIcon, ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useQuery } from "@connectrpc/connect-query"; import { AdminServiceQueries, @@ -26,7 +26,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -173,9 +173,15 @@ export function OrganizationPatView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index c5ef5df251..2af4b31513 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -7,7 +7,7 @@ import { } from "@raystack/apsara"; import { PageTitle } from "~/admin/components/PageTitle"; import styles from "./projects.module.css"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { getColumns } from "./columns"; import type { SearchOrganizationProjectsResponse_OrganizationProject } from "@raystack/proton/frontier"; import { AdminServiceQueries } from "@raystack/proton/frontier"; @@ -29,7 +29,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -160,9 +160,15 @@ export function OrganizationProjectsView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; 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 c0027a9985..3a8d1fb575 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -1,6 +1,6 @@ import { AlertDialog, DataTable, Dialog, EmptyState, Flex } from "@raystack/apsara"; import type { DataTableQuery } from "@raystack/apsara"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Skeleton from "react-loading-skeleton"; import { AdminServiceQueries, @@ -139,12 +139,17 @@ export const ProjectMembersDialog = ({ }); }, []); + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = useCallback(async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more project members:", error); + } finally { + isLoadingMoreRef.current = false; } }, [hasNextPage, isFetchingNextPage, fetchNextPage]); diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index b43cbab333..554d5b816e 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -3,7 +3,7 @@ import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import styles from "./tokens.module.css"; import { CoinIcon } from "@raystack/apsara/icons"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { FrontierServiceQueries } from "@raystack/proton/frontier"; @@ -18,7 +18,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { @@ -143,9 +143,15 @@ export function OrganizationTokensView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index 42f4f25b36..bff5a95634 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -1,6 +1,6 @@ import { Button, DataTable, EmptyState, Flex, type DataTableQuery, type DataTableSort } from "@raystack/apsara"; import { OrganizationIcon } from "@raystack/apsara/icons"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { OrganizationsNavabar } from "./navbar"; import styles from "./list.module.css"; import { getColumns } from "./columns"; @@ -68,7 +68,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; @@ -167,12 +167,17 @@ export const OrganizationListView = ({ }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more organizations:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index 36b2b5f9d3..9144d663ac 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -5,6 +5,7 @@ import styles from "./list.module.css"; import { getColumns } from "./columns"; import { PageTitle } from "../../../components/PageTitle"; import UserIcon from "../../../assets/icons/UsersIcon"; +import { useRef } from "react"; import { useInfiniteQuery } from "@connectrpc/connect-query"; import { AdminServiceQueries, type User } from "@raystack/proton/frontier"; import { @@ -36,7 +37,7 @@ 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. + // Must match DataTable's mount emit, or it refetches. sort: [DEFAULT_SORT], }; @@ -96,12 +97,17 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more users:", error); + } finally { + isLoadingMoreRef.current = false; } }; From b86af87f7a05e9c08551471e91a29bcceae005cb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 17 Aug 2026 20:53:47 +0530 Subject: [PATCH 13/15] fix(admin): cover both billing legs in isBillingAccountLoading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account id comes from listBillingAccounts, and getBillingAccount stays disabled until it arrives — so on its own it reports "not loading" while its consumers are still waiting. Keeping the detail tab mounted through the billing load made that window visible for the first time: the side panel showed N/A for the billing name, email and address, and 0 / Prepaid under tokens, as though those were settled values rather than pending ones. OR the two legs together in the context, and have the side-panel sections wait on that combined flag instead of their own queries, which are disabled for the same reason. The same window left Edit billing and Add tokens submittable before the ids existed, so guard both submits and disable the Add button while billing loads. An org with no billing account still settles to false: the list resolves empty and getBillingAccount never enables. --- .../organizations/details/edit/billing.tsx | 1 + .../views/organizations/details/index.tsx | 36 +++++++++---------- .../details/layout/add-tokens-dialog.tsx | 12 +++++-- .../side-panel/billing-details-section.tsx | 12 +++++-- .../side-panel/tokens-details-section.tsx | 3 +- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/edit/billing.tsx b/web/sdk/admin/views/organizations/details/edit/billing.tsx index af6277c802..225b4446be 100644 --- a/web/sdk/admin/views/organizations/details/edit/billing.tsx +++ b/web/sdk/admin/views/organizations/details/edit/billing.tsx @@ -126,6 +126,7 @@ export function EditBillingPanel({ open = false, onClose }: EditBillingPanelProp ); const onSubmit = async (data: BillingDetailsForm) => { + if (!organizationId || !billingId) return; try { // For prepaid, set values to 0; for postpaid, use form values const creditMinValue = data.tokenPaymentType === "prepaid" ? 0n : BigInt(data.creditMin); diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 5b46770e30..75243f9ccd 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -141,16 +141,18 @@ export const OrganizationDetailsView = ({ const roles = [...defaultRoles, ...organizationRoles]; - // Fetch billing accounts list - const { data: firstBillingAccountId = "", error: billingAccountsError } = - useQuery( - FrontierServiceQueries.listBillingAccounts, - { orgId: organizationId || "" }, - { - enabled: !!organizationId, - select: (data) => data?.billingAccounts?.[0]?.id || "", - }, - ); + const { + data: firstBillingAccountId = "", + isLoading: isBillingAccountsLoading, + error: billingAccountsError, + } = useQuery( + FrontierServiceQueries.listBillingAccounts, + { orgId: organizationId || "" }, + { + enabled: !!organizationId, + select: (data) => data?.billingAccounts?.[0]?.id || "", + }, + ); // Fetch billing account details const { @@ -176,6 +178,9 @@ export const OrganizationDetailsView = ({ const billingAccount = billingAccountData?.billingAccount; const billingAccountDetails = billingAccountData?.billingAccountDetails; + // getBillingAccount is disabled until the list yields an id. + const isBillingLoading = isBillingAccountsLoading || isBillingAccountLoading; + // Fetch billing balance const { data: tokenBalance = "0", @@ -229,12 +234,7 @@ export const OrganizationDetailsView = ({ 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 - */ + // Billing waits on an id, so including it here remounted the tab mid-load. const isLoading = isOrganizationLoading || isDefaultRolesLoading || isOrgRolesLoading; return ( @@ -245,10 +245,10 @@ export const OrganizationDetailsView = ({ roles, billingAccount, billingAccountDetails, - isBillingAccountLoading, + isBillingAccountLoading: isBillingLoading, fetchBillingAccountDetails, tokenBalance, - isTokenBalanceLoading, + isTokenBalanceLoading: isBillingAccountsLoading || isTokenBalanceLoading, fetchTokenBalance, updateKYCDetails, kycDetails, diff --git a/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx b/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx index ae6a25b5d2..bf7a255464 100644 --- a/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx +++ b/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx @@ -37,8 +37,13 @@ const addTokensSchema = z.object({ type AddTokenRequestType = z.infer; export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { - const { organization, billingAccount, fetchTokenBalance, tokenProductId } = - useContext(OrganizationContext); + const { + organization, + billingAccount, + isBillingAccountLoading, + fetchTokenBalance, + tokenProductId, + } = useContext(OrganizationContext); const queryClient = useQueryClient(); const transport = useTransport(); const organisationId = organization?.id || ""; @@ -80,7 +85,7 @@ export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { ); const onSubmit = async (product_body: AddTokenRequestType) => { - if (!organisationId) return; + if (!organisationId || !billingAccountId) return; await delegatedCheckout( create(DelegatedCheckoutRequestSchema, { orgId: organisationId, @@ -153,6 +158,7 @@ export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { data-test-id="add-tokens-invite-button" type="submit" loaderText="Adding..." + disabled={isBillingAccountLoading || isSubmitting} loading={isSubmitting} > Add diff --git a/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx b/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx index efd2b0f488..8c1d4146dc 100644 --- a/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx +++ b/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx @@ -14,12 +14,17 @@ import { } from "~/admin/utils/connect-timestamp"; export const BillingDetailsSection = () => { - const { billingAccount, organization } = useContext(OrganizationContext); + const { billingAccount, organization, isBillingAccountLoading } = + useContext(OrganizationContext); const organizationId = organization?.id || ""; const billingAccountId = billingAccount?.id || ""; - const { data: upcomingInvoice, isLoading, error } = useQuery( + const { + data: upcomingInvoice, + isLoading: isUpcomingInvoiceLoading, + error, + } = useQuery( FrontierServiceQueries.getUpcomingInvoice, create(GetUpcomingInvoiceRequestSchema, { orgId: organizationId, @@ -36,6 +41,9 @@ export const BillingDetailsSection = () => { console.error("Error fetching upcoming invoice:", error); } }, [error]); + + const isLoading = isBillingAccountLoading || isUpcomingInvoiceLoading; + const due_date = upcomingInvoice?.dueDate || upcomingInvoice?.periodEndAt; const stripeLink = billingAccount?.providerId diff --git a/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx b/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx index cc5027e301..d5a29e2e5b 100644 --- a/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx +++ b/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx @@ -14,6 +14,7 @@ export const TokensDetailsSection = () => { billingAccount, organization, isTokenBalanceLoading, + isBillingAccountLoading, billingAccountDetails, } = useContext(OrganizationContext); @@ -38,7 +39,7 @@ export const TokensDetailsSection = () => { } }, [error]); - const isLoading = isTokensLoading; + const isLoading = isBillingAccountLoading || isTokensLoading; return ( From b2509ec316f477ba211517b04c1ca90a38dc2281 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 17 Aug 2026 20:54:00 +0530 Subject: [PATCH 14/15] fix(admin): seed the resolved org only into an empty cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slug resolve and the view's fetch are separate cache entries, and an org edit invalidates only the id-keyed one. Seeding unconditionally meant a remount could write the slug copy — up to its five minute staleTime old — over fresher data, and the new default staleTime then kept it there with nothing to trigger a correction. Seed only when the id key holds nothing, and skip it entirely for a UUID URL, where the resolve already owns that key and the write was a no-op that just pushed dataUpdatedAt forward. Also records why the resolve tolerates disabled orgs: GetOrganization returns them to superusers only, the org-state gate answers everyone else with FailedPrecondition, and the console is superuser-only. --- .../src/pages/organizations/details/index.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index b5feb70a5e..4e6832115f 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -60,8 +60,9 @@ export default function OrganizationDetailsPage() { /* * Cold-load resolve (only when state carries no id): - * - getOrganization takes an id OR a slug and returns disabled orgs too, - * so a single call covers every URL form (server GetRaw branches on UUID) + * - getOrganization takes an id OR a slug, so a single call covers every URL + * form (server GetRaw branches on UUID) + * - disabled orgs resolve for superusers only; the console is superuser-only * - a UUID param is already the id, but we still resolve to read the slug + * state for the canonical-URL rewrite below */ @@ -84,24 +85,24 @@ 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. - */ + /* The slug resolve caches under the slug, so seed the id key the view uses. + * In render, not an effect: the view mounts this commit. Empty keys only — + * this copy can be stale, and edits invalidate the id key, not the slug. */ const primedOrgId = useRef(undefined); - if (org?.id && primedOrgId.current !== org.id) { + if (org?.id && org.id !== urlParam && 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 }), - ); + const orgKey = createConnectQueryKey({ + schema: FrontierServiceQueries.getOrganization, + transport, + input: { id: org.id }, + cardinality: 'finite', + }); + if (queryClient.getQueryData(orgKey) === undefined) { + queryClient.setQueryData( + orgKey, + create(GetOrganizationResponseSchema, { organization: org }), + ); + } } /* From 5ff090275e94a3a63ff064b3bead24488c18b646 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 17 Aug 2026 20:54:53 +0530 Subject: [PATCH 15/15] docs(admin): keep only the load-bearing comments Drop the notes that restated the code beneath them and cut the rest to the one fact a reader needs in order not to undo the change. Also corrects the QueryClient note: plans and products already set staleTime: Infinity, so navigating never refetched them. Roles was the case the default actually fixes. --- web/apps/admin/src/contexts/ConnectProvider.tsx | 7 ++----- web/sdk/admin/hooks/useOrgMembersMap.ts | 8 ++------ web/sdk/admin/views/users/list/invite-users.tsx | 5 +---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/web/apps/admin/src/contexts/ConnectProvider.tsx b/web/apps/admin/src/contexts/ConnectProvider.tsx index 1ea1f0bf51..62b5d9a92f 100644 --- a/web/apps/admin/src/contexts/ConnectProvider.tsx +++ b/web/apps/admin/src/contexts/ConnectProvider.tsx @@ -4,11 +4,8 @@ import type { ReactNode } from "react"; import { TransportProvider } from "@connectrpc/connect-query"; import { jsonTransport as transport } from "~/connect/transport"; -/* - * 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. - */ +/* Otherwise every mount refetches. Mutations invalidate their own keys; + * the search tables opt out with staleTime: 0. */ const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts index 8ec0b74a97..ca1fec98b8 100644 --- a/web/sdk/admin/hooks/useOrgMembersMap.ts +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -2,7 +2,7 @@ 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. */ +// Stable identity so react-query memoizes the select. const toMembersMap = (data?: ListOrganizationUsersResponse) => (data?.users || []).reduce( (acc, user) => { @@ -12,11 +12,7 @@ const toMembersMap = (data?: ListOrganizationUsersResponse) => {} 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. - */ +/** Org members keyed by id. Deduped across callers; empty orgId disables. */ export const useOrgMembersMap = (orgId?: string) => useQuery( FrontierServiceQueries.listOrganizationUsers, diff --git a/web/sdk/admin/views/users/list/invite-users.tsx b/web/sdk/admin/views/users/list/invite-users.tsx index 493802bd0c..db456057ee 100644 --- a/web/sdk/admin/views/users/list/invite-users.tsx +++ b/web/sdk/admin/views/users/list/invite-users.tsx @@ -55,10 +55,7 @@ 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. - */ + // This mounts with the page, so gate on open. const { data: organizations, isLoading: isOrganizationsLoading,