Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0633432
fix(admin): stop server tables issuing a duplicate request on mount
Shreyag02 Aug 10, 2026
85174f4
fix(admin): drop the empty default sort on project members
Shreyag02 Aug 10, 2026
35ec779
fix(admin): keep the org detail tab mounted while billing loads
Shreyag02 Aug 10, 2026
968f603
fix(admin): stop fast scrolling firing redundant page requests
Shreyag02 Aug 10, 2026
c8db582
fix(admin): scope the members invalidation to its organization
Shreyag02 Aug 10, 2026
75bc15c
fix(admin): give queries a default staleTime
Shreyag02 Aug 10, 2026
c101560
fix(admin): reuse the resolved org instead of refetching it by id
Shreyag02 Aug 10, 2026
c0a73c5
fix(admin): fetch the org member map only where it is used
Shreyag02 Aug 10, 2026
7a9491e
fix(admin): fetch invite dialog options only when it opens
Shreyag02 Aug 11, 2026
e39a065
fix(admin): guard the last three load-more handlers
Shreyag02 Aug 11, 2026
cec34fa
docs(admin): tighten the comments added in this branch
Shreyag02 Aug 11, 2026
31015f5
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 Aug 11, 2026
b5d4a93
fix(admin): latch load-more against scroll bursts
Shreyag02 Aug 17, 2026
b86af87
fix(admin): cover both billing legs in isBillingAccountLoading
Shreyag02 Aug 17, 2026
b2509ec
fix(admin): seed the resolved org only into an empty cache key
Shreyag02 Aug 17, 2026
5ff0902
docs(admin): keep only the load-bearing comments
Shreyag02 Aug 17, 2026
b0e8108
fix(admin): disable Add tokens when there is no billing account
Shreyag02 Aug 18, 2026
79c7f9e
fix(admin): log member map failures again
Shreyag02 Aug 18, 2026
2cfe257
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 Aug 18, 2026
b11d0d9
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 Aug 19, 2026
bbd845a
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 Aug 24, 2026
5475994
fix(admin): scope the staleTime to the queries that want it
Shreyag02 Aug 24, 2026
5d85db0
fix(admin): drop the redundant loading term on Add tokens
Shreyag02 Aug 24, 2026
2280197
fix(admin): seed the resolved org from an effect, not during render
Shreyag02 Aug 24, 2026
2c185b3
refactor(admin): share the server table query state and load-more latch
Shreyag02 Aug 24, 2026
28da34a
fix(admin): disable Save on Edit billing without a billing account
Shreyag02 Aug 24, 2026
ab39bd2
refactor(admin): drop the orphaned DataTableQuery import
Shreyag02 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions web/apps/admin/src/pages/organizations/details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { OrganizationDetailsView, useAdminPaths } from '@raystack/frontier/admin';
import { useCallback, useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useEffect, useLayoutEffect, 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';
Expand Down Expand Up @@ -33,6 +38,8 @@ export default function OrganizationDetailsPage() {
const paths = useAdminPaths();
const { config } = useContext(AppContext);
const [countries, setCountries] = useState<string[]>([]);
const queryClient = useQueryClient();
const transport = useTransport();

const incomingOrgId = (location.state as { orgId?: string } | null)?.orgId;

Expand All @@ -53,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
*/
Expand All @@ -77,6 +85,25 @@ export default function OrganizationDetailsPage() {
const orgId = stateOrgId || (paramIsId ? urlParam : org?.id);
const notFound = needsResolve && isSuccess && !org?.id;

/* Resolve caches under the slug, so seed the id key the view reads. Layout,
* not passive: the view subscribes to that key in a passive effect this same
* commit. Empty keys only — this copy can go stale, edits invalidate the id. */
useLayoutEffect(() => {
if (!org?.id || org.id === urlParam) return;
const orgKey = createConnectQueryKey({
schema: FrontierServiceQueries.getOrganization,
transport,
input: { id: org.id },
cardinality: 'finite',
});
if (queryClient.getQueryData(orgKey) === undefined) {
queryClient.setQueryData(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seeds the cache with setQueryData in the render body rather than in an effect. A write during render can trigger React's "Cannot update a component while rendering a different component" warning if another mounted component already observes that query, and a discarded concurrent render still mutates the global cache. Moving it into a useEffect keeps it out of the render path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved out of render — but to a layout effect rather than a passive one, because a plain useEffect lands too late to be worth anything.

The view mounts in this same commit, and react-query subscribes from useSyncExternalStore (useBaseQuery.js:56), which React runs as a passive effect. Passive effects for a commit run after all layout effects, so:

Seed runs in Child fetches
render (what you flagged) 0
layout effect 0
passive effect 1

Measured against real React 19.2.4 + react-query 5.90.21, identical under StrictMode. So a useEffect here would have quietly given back the GetOrganization this was saving.

The layout effect answers both halves of your comment: it's out of the render path, and layout effects only run on committed renders, so a discarded concurrent render no longer mutates the cache. The useRef latch went with it — the dep array plus the existing empty-key check cover it.

Confirmed end to end since: a cold load on a slug URL now issues exactly one GetOrganization.

orgKey,
create(GetOrganizationResponseSchema, { organization: org }),
);
}
}, [org, urlParam, queryClient, transport]);

/*
* Old UUID bookmark → canonical slug URL:
* - one live URL per org; replace keeps the back-button sane
Expand Down
49 changes: 49 additions & 0 deletions web/sdk/admin/hooks/useLoadMore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useCallback, useRef } from "react";

export interface UseLoadMoreOptions {
hasNextPage?: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
/** Skip while the query is errored, so scrolling cannot retry a failed page. */
isError?: boolean;
/** Names the rows in the console message, e.g. "audit logs". */
label: string;
}

/*
Guarded "load more" for a server table's infinite query.
- VirtualizedContent calls this straight from onScroll and react-query
notifies observers on a macrotask, so hasNextPage/isFetchingNextPage are
still last render's values through a scroll burst
- fetchNextPage defaults to cancelRefetch: true, so an unguarded repeat aborts
the in-flight page and re-issues it; only the ref flips in time to stop that
- the render-derived flags stay as a cheap first filter
*/
export const useLoadMore = ({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError,
label,
}: UseLoadMoreOptions) => {
const isLoadingMoreRef = useRef(false);

return useCallback(async () => {
if (
!hasNextPage ||
isFetchingNextPage ||
isError ||
isLoadingMoreRef.current
) {
return;
}
isLoadingMoreRef.current = true;
try {
await fetchNextPage();
} catch (error) {
console.error(`Error loading more ${label}:`, error);
} finally {
isLoadingMoreRef.current = false;
}
}, [hasNextPage, isFetchingNextPage, isError, fetchNextPage, label]);
};
26 changes: 26 additions & 0 deletions web/sdk/admin/hooks/useOrgMembersMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useQuery } from "@connectrpc/connect-query";
import { FrontierServiceQueries, type User } from "@raystack/proton/frontier";
import type { ListOrganizationUsersResponse } from "@raystack/proton/frontier";
import { SHARED_QUERY_STALE_TIME } from "~/admin/utils/constants";

// Stable identity so react-query memoizes the select.
const toMembersMap = (data?: ListOrganizationUsersResponse) =>
(data?.users || []).reduce(
(acc, user) => {
acc[user.id || ""] = user;
return acc;
},
{} as Record<string, User>,
);

/** Org members keyed by id. Deduped across callers; empty orgId disables. */
export const useOrgMembersMap = (orgId?: string) =>
useQuery(
FrontierServiceQueries.listOrganizationUsers,
{ id: orgId || "" },
{
enabled: !!orgId,
staleTime: SHARED_QUERY_STALE_TIME,
select: toMembersMap,
},
);
6 changes: 4 additions & 2 deletions web/sdk/admin/hooks/useOrganizationRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ListRolesRequestSchema,
ListOrganizationRolesRequestSchema,
} from "@raystack/proton/frontier";
import { SCOPES } from "~/admin/utils/constants";
import { SCOPES, SHARED_QUERY_STALE_TIME } from "~/admin/utils/constants";

interface UseOrganizationRolesOptions {
/** Skip both fetches while false. Defaults to true. */
Expand All @@ -16,7 +16,7 @@ interface UseOrganizationRolesOptions {
/*
Roles assignable within an org: the platform's defaults plus the org's custom
ones. Both halves are needed — a role id can come from either.
- react-query caches per key, so repeat callers share one fetch
- nothing writes roles, so repeat callers share one cached fetch
- pass undefined/empty to skip the org-scoped half
*/
export const useOrganizationRoles = (
Expand All @@ -32,6 +32,7 @@ export const useOrganizationRoles = (
create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }),
{
enabled,
staleTime: SHARED_QUERY_STALE_TIME,
select: (data) => data?.roles || [],
},
);
Expand All @@ -48,6 +49,7 @@ export const useOrganizationRoles = (
}),
{
enabled: enabled && !!orgId,
staleTime: SHARED_QUERY_STALE_TIME,
select: (data) => data?.roles || [],
},
);
Expand Down
91 changes: 91 additions & 0 deletions web/sdk/admin/hooks/useServerTableQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { DataTableQuery, DataTableSort } from "@raystack/apsara";
import type { RQLRequest } from "@raystack/proton/frontier";

import { DEFAULT_PAGE_SIZE } from "~/utils/connect-pagination";
import {
transformDataTableQueryToRQLRequest,
type TransformOptions,
} from "~/utils/transform-query";
import { useDebouncedValue } from "~hooks";

export interface ServerTableQueryOptions {
/** Sort applied until the user picks another. Must match the table's `defaultSort`. */
defaultSort?: DataTableSort;
/** Field name mapping for the RQL request. Read through a ref, so an inline object is fine. */
transformOptions?: TransformOptions;
/** Search owned outside the table, e.g. the organization page's shared box. */
search?: string;
/** Adjust the query before it becomes a request, e.g. converting units. */
// eslint-disable-next-line no-unused-vars -- callback param name is for type documentation
mapQuery?: (query: DataTableQuery) => DataTableQuery;
/** Debounce applied to the request, not to the table's own state. */
debounceMs?: number;
}

export interface ServerTableQuery {
/** Pass to DataTable's `query` prop. Updates immediately. */
tableQuery: DataTableQuery;
/** Pass to the RPC. Trails `tableQuery` by `debounceMs`. */
rqlQuery: RQLRequest;
/** Pass to DataTable's `onTableQueryChange` prop. */
// eslint-disable-next-line no-unused-vars -- callback param name is for type documentation
onTableQueryChange: (query: DataTableQuery) => void;
}

/**
* Query state for a `mode="server"` DataTable.
*
* The initial query carries `defaultSort` on purpose. DataTable seeds its own
* state from that prop and emits it on mount unconditionally; if the initial
* query here disagreed, that emit would change the request and every table
* would fetch its first page twice. Keep the `defaultSort` passed to DataTable
* and the one passed here identical.
*/
export function useServerTableQuery({
defaultSort,
transformOptions,
search,
mapQuery,
debounceMs = 200,
}: ServerTableQueryOptions = {}): ServerTableQuery {
const [tableQuery, setTableQuery] = useState<DataTableQuery>(() => ({
offset: 0,
limit: DEFAULT_PAGE_SIZE,
sort: defaultSort ? [defaultSort] : [],
}));

/*
* Field mappings are fixed per view, so read them through a ref. Callers
* passing an inline object would otherwise change the memo's identity every
* render, restarting the debounce timer and never letting it settle.
*/
const transformOptionsRef = useRef(transformOptions);
transformOptionsRef.current = transformOptions;
const mapQueryRef = useRef(mapQuery);
mapQueryRef.current = mapQuery;

const computedQuery = useMemo(() => {
const mapped = mapQueryRef.current
? mapQueryRef.current(tableQuery)
: tableQuery;
const rql = transformDataTableQueryToRQLRequest(
mapped,
transformOptionsRef.current,
);
return search === undefined ? rql : { ...rql, search };
}, [tableQuery, search]);

const rqlQuery = useDebouncedValue(computedQuery, debounceMs);

/* Any change to filters, sort or search starts again from the first page. */
const onTableQueryChange = useCallback((query: DataTableQuery) => {
setTableQuery({
...query,
offset: 0,
limit: query.limit || DEFAULT_PAGE_SIZE,
});
}, []);

return { tableQuery, rqlQuery, onTableQueryChange };
}
4 changes: 4 additions & 0 deletions web/sdk/admin/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const DEFAULT_ROLES = {

export const NULL_DATE = "0001-01-01T00:00:00Z";

/* Not a client-wide default: only safe where every writer invalidates the
* key, so each query opts in and says why. */
export const SHARED_QUERY_STALE_TIME = 30 * 1000;

export interface AdminTerminologyConfig {
organization?: EntityTerminologies;
project?: EntityTerminologies;
Expand Down
Loading
Loading