Skip to content

fix(admin): stop duplicate API calls across admin tables - #1869

Draft
Shreyag02 wants to merge 13 commits into
mainfrom
fix/admin-duplicate-api-calls
Draft

fix(admin): stop duplicate API calls across admin tables#1869
Shreyag02 wants to merge 13 commits into
mainfrom
fix/admin-duplicate-api-calls

Conversation

@Shreyag02

@Shreyag02 Shreyag02 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Every server-mode table in the admin UI was fetching its first page twice, and the organization detail page could fetch its active tab up to four times. Both were visible on staging as (canceled) requests in the network tab.

The root cause is small: our tables pass defaultSort to DataTable but leave sort out of the initial query. DataTable merges defaultSort in and emits it on mount, which changes the connect-query cache key and triggers a second request. The first one is then aborted mid-flight — after the server has already done the work.

This PR fixes that and the other duplicate-fetch issues found while tracing it, then moves all 11 server tables onto one shared hook so the bug can't come back one view at a time.

Changes

Duplicate requests

Change What was happening Scope
Seed the initial query with sort DataTable's mount emit changed the request, so every table fetched page 1 twice All 11 server tables
Keep the org detail tab mounted while billing loads The tab unmounted and remounted mid-load, replaying every request in it details/index.tsx
Reuse the org resolved from a slug URL The same org was fetched again by id under a different cache key Org detail page
Guard "load more" on hasNextPage / isFetchingNextPage Fast scrolling fired several requests to load a single page Org list, users, audit logs
Scope the members invalidation to its own org An empty input matched partially, invalidating every org's cached member list Members tab
Fetch the org member map only in the projects tab The full member list was fetched on every org page, read by one tab Org context → projects tab
Add a default staleTime of 30s staleTime: 0 + refetchOnMount refetched reference data on every navigation App-wide

Correctness and cleanup

Change What was happening
Skeleton + tooltip when an org lookup fails on the admins list A failed lookup silently printed the raw UUID as if it were the org name
Drop the empty defaultSort on project members It sent a sort with an empty field name; that endpoint ignores sort entirely
Pass the audit-log export query as a prop It lived in the react-query cache under a key no query owned, so it was garbage collected after 5 min idle and exported unfiltered
Remove the unused updateOrganization context method It wrote to a malformed cache key, and nothing called it

Refactor

Change Why
Add useServerTableQuery, migrate all 11 server tables Four hand-rolled variants of the same query state had drifted apart — which is why the bug existed in all 11 views and was fixed in none

Technical Details

Why the key changes. connect-query builds cache keys with createMessageKey, which omits unset fields. sort: [] and sort: [{name: "created_at", …}] therefore hash differently — same query in our heads, two cache entries in practice. Seeding the initial sort makes the mount emit structurally identical to what's already in state, so the key never changes.

Why the tab remounted. The layout's isLoading included isBillingAccountLoading, but the listBillingAccounts call that enables that query wasn't in the gate. A disabled query reports isLoading: false, so:

Step Gate Result
Org + roles still loading true Spinner
Org + roles settled, billing not yet enabled false Tab mounts, tables fetch
listBillingAccounts resolves, billing query enables true Tab unmounts
Billing settles false Tab remounts, tables fetch again

The gate now only includes queries enabled from the first render, so it can flip once and stay there.

Two changes that only work together. Seeding the resolved org into the cache does nothing while staleTime is 0 — the entry is stale on arrival and the view refetches anyway. Please don't land one without the other:

Setup Requests
Not seeded, staleTime 30s 1
Seeded, staleTime 0 1
Seeded, staleTime 30s 0

The hook.

Detail Note
Debounces the request, not the table state Org and user lists previously debounced everything, so the grid lagged behind typing. It should now feel more responsive while the RPC still waits for typing to settle
transformOptions and mapQuery read through refs An inline object at the call site can't restart the debounce on every render
mapQuery escape hatch Org invoices converts its amount filter to cents; project members drops sort

Out of scope, worth knowing. Staging also shows a GetOrganization returning 403. It isn't a duplicate-call artifact — app/organization#get grants superusers access only via platform->superuser, which needs the org's platform relation tuple. That tuple is written once by AttachToPlatform at creation, with no backfill or reconcile path if it's missing. Needs the failing org id to confirm; raising separately with whoever owns the authz work.

Test Plan

  • Build and type checking passes
  • Manual testing completed
Check Result
pnpm build in web/sdk Succeeds
tsc --noEmit Same 21 pre-existing errors before and after; none in touched files
Cache key, replaying real Apsara + connect-query code Keys differ before the change, match after
Cache seeding vs staleTime Seeded + staleTime: 0 → 1 request; seeded + 30s → 0 requests

Not yet manually tested — what to look at:

# Check Expected
1 Network tab on /frontier-connect, prod build No (canceled) entries on org list, users, audit logs, invoices
2 Org detail cold load from a slug URL One GetOrganization; tab does not flash
3 Typing in the org / user list search Grid stays responsive, request still debounced
4 Sort, filter and infinite scroll on each table Unchanged behaviour, one request per change
5 Projects tab Member avatars render; add-members dropdown still filters

Use a prod build — dev doubles every request under StrictMode. Run pnpm build in web/sdk first, since the admin app serves the prebuilt admin/dist.

SQL Safety (if your PR touches *_repository.go or goqu.*)

Not applicable — frontend only, no Go or query-building changes. One backend file was read while confirming that project_users_repository.go ignores sort, but nothing under internal/ is modified.

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.
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.
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.
OrgCell ignored the query state and rendered
{org?.title || org?.name || orgId}, so a failed lookup silently printed
the raw org id in the organization column with a disabled button. A
denied or unreachable org was indistinguishable from one whose title
happens to be missing.

Render a skeleton while the lookup is in flight, and on failure show
"Unavailable" with the error and the id behind a tooltip.
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.
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.
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.
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.
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.
It wrote to [schema, {id}], which is not the shape connect-query uses for
its keys — those are ["connect-query", {serviceName, methodName, transport,
cardinality, input}]. The write therefore landed on an entry no query ever
reads and updated nothing.

Nothing called it: the edit panel invalidates the org query directly. Rather
than fix a key for a method with no callers, remove it and leave
invalidation as the one way the org is refreshed.
The current table query was written into the react-query cache under
["audit-logs", "table-query"] so the download button could read it back.
No query owns that key, so the entry is garbage collected once idle for
the default gcTime — after which the export ran with an undefined query
and downloaded the unfiltered log.

The value is plain component state already held one level up, so pass it
down instead of routing it through the cache.
The organization tabs each rebuilt the same query state by hand: table
state, a memo transforming it to RQL, a debounce, and a change handler.
Four variations of that had drifted across the views, which is why the
duplicate-request-on-mount bug existed in all of them and had been fixed
in none.

useServerTableQuery owns that shape, including the initial sort that has
to agree with DataTable's defaultSort. Views that need to adjust the query
before it becomes a request pass mapQuery; the organization invoices table
uses it to convert its amount filter to cents.

Field mappings and mapQuery are read through refs so an inline object at
the call site cannot restart the debounce on every render.
The organization and user lists debounced their whole table state, so the
grid lagged behind typing; audit logs and project members carried a
{query, rqlRequest} pair through state; the invoices list had no debounce
at all. All four shapes did the same job as the tabs already migrated.

They now use useServerTableQuery, which debounces the request rather than
the table state, so the grid stays responsive while the RPC still waits
for typing to settle.

Project members keeps its behaviour through mapQuery: that endpoint
ignores sort, so the request drops it as the hand-written handler did.

All 11 mode="server" tables now share one implementation, and the initial
sort agreeing with defaultSort is a property of the hook rather than
something each view has to remember.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Aug 10, 2026 11:09pm

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Improved server-side table searching, sorting, filtering, and pagination across administration views.
    • Organization and user lists now provide smoother loading and prevent duplicate page-load requests.
    • Organization details load more efficiently, while billing-related sections can render independently.
    • Organization member data is handled consistently across projects and member management.
  • Bug Fixes
    • Added clearer loading and unavailable states for organization information, including helpful details when lookups fail.
    • Improved cache behavior to reduce unnecessary repeated requests while preserving fresh search results.

Walkthrough

The PR centralizes server-side table query state and RQL generation across admin views. It adds organization member lookup through a dedicated hook, removes related context fields, primes organization caches, improves organization lookup states, and configures a 30-second default query freshness period.

Changes

Admin query and organization data refactor

Layer / File(s) Summary
Shared query infrastructure
web/sdk/admin/hooks/useServerTableQuery.ts
Adds the public hook contracts and centralizes pagination, sorting, transformation, search, debouncing, and RQL request generation.
Organization data ownership
web/apps/admin/src/contexts/ConnectProvider.tsx, web/apps/admin/src/pages/organizations/details/index.tsx, web/sdk/admin/hooks/useOrgMembersMap.ts, web/sdk/admin/views/organizations/details/..., web/sdk/admin/views/admins/columns.tsx
Sets default query freshness to 30 seconds, primes organization cache entries, moves member lookup into useOrgMembersMap, removes member data from organization context, and adds loading and unavailable states for organization cells.
Server-table adoption
web/sdk/admin/views/audit-logs/..., web/sdk/admin/views/invoices/index.tsx, web/sdk/admin/views/organizations/..., web/sdk/admin/views/users/list/list.tsx
Replaces local table-query state and manual RQL handling with useServerTableQuery. Audit-log export receives the active RQL request directly, and pagination handlers prevent overlapping requests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: paansinghcoder

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Shreyag02 Shreyag02 added the Do not merge Label to indicate that the PR is not ready to be merged even though might be (or not) approvals. label Aug 10, 2026
@Shreyag02
Shreyag02 marked this pull request as draft August 10, 2026 23:10
@Shreyag02 Shreyag02 changed the title Fix/admin duplicate api calls fix(admin): stop duplicate API calls across admin tables Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a49db67e-1c52-40d1-9e9f-dff6cca3a8b0

📥 Commits

Reviewing files that changed from the base of the PR and between 8e22bcf and 8cc1965.

📒 Files selected for processing (21)
  • web/apps/admin/src/contexts/ConnectProvider.tsx
  • web/apps/admin/src/pages/organizations/details/index.tsx
  • web/sdk/admin/hooks/useOrgMembersMap.ts
  • web/sdk/admin/hooks/useServerTableQuery.ts
  • web/sdk/admin/views/admins/columns.tsx
  • web/sdk/admin/views/audit-logs/index.tsx
  • web/sdk/admin/views/audit-logs/navbar.tsx
  • web/sdk/admin/views/audit-logs/util.ts
  • web/sdk/admin/views/invoices/index.tsx
  • web/sdk/admin/views/organizations/details/apis/index.tsx
  • web/sdk/admin/views/organizations/details/contexts/organization-context.tsx
  • web/sdk/admin/views/organizations/details/index.tsx
  • web/sdk/admin/views/organizations/details/invoices/index.tsx
  • web/sdk/admin/views/organizations/details/members/index.tsx
  • web/sdk/admin/views/organizations/details/pat/index.tsx
  • web/sdk/admin/views/organizations/details/projects/index.tsx
  • web/sdk/admin/views/organizations/details/projects/members/index.tsx
  • web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx
  • web/sdk/admin/views/organizations/details/tokens/index.tsx
  • web/sdk/admin/views/organizations/list/index.tsx
  • web/sdk/admin/views/users/list/list.tsx
💤 Files with no reviewable changes (2)
  • web/sdk/admin/views/audit-logs/util.ts
  • web/sdk/admin/views/organizations/details/contexts/organization-context.tsx

/** 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. */
mapQuery?: (query: DataTableQuery) => DataTableQuery;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 2 'no-unused-vars|argsIgnorePattern|varsIgnorePattern' \
  -g 'eslint.config.*' -g '.eslintrc*' -g 'package.json' .

Repository: raystack/frontier

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file ---'
sed -n '1,100p' web/sdk/admin/hooks/useServerTableQuery.ts

printf '%s\n' '--- repository lint/config files ---'
git ls-files | rg '(^|/)(eslint\.config\.[^/]+|\.eslintrc[^/]*|package\.json|.*lint.*)$' | head -200

printf '%s\n' '--- unused-argument conventions ---'
rg -n -S 'argsIgnorePattern|varsIgnorePattern|no-unused-vars|unused.*(param|arg)|^ *[_$][A-Za-z0-9_]*[,:)]' \
  web package.json .github 2>/dev/null | head -300

Repository: raystack/frontier

Length of output: 4286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ESLint configuration ---'
cat -n web/.eslintrc.js
printf '%s\n' '--- shared ESLint configuration ---'
cat -n web/tools/eslint-config/index.js
printf '%s\n' '--- relevant package scripts and dependencies ---'
node - <<'JS'
const fs = require("fs");
for (const file of ["web/package.json", "web/sdk/package.json", "web/apps/admin/package.json"]) {
  if (!fs.existsSync(file)) continue;
  const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
  console.log(`--- ${file} ---`);
  console.log(JSON.stringify({
    scripts: pkg.scripts,
    eslintConfig: pkg.eslintConfig,
    devDependencies: pkg.devDependencies,
    dependencies: pkg.dependencies
  }, null, 2));
}
JS
printf '%s\n' '--- existing declaration-only parameter suppressions ---'
cat -n web/sdk/admin/components/PageHeader.tsx
rg -n -C 3 'eslint-disable.*no-unused-vars|callback param name|type documentation' web --glob '*.{js,jsx,ts,tsx}'

Repository: raystack/frontier

Length of output: 10156


Suppress the unused declaration parameter at line 20.

Add a targeted no-unused-vars suppression, consistent with web/sdk/admin/components/PageHeader.tsx. The query parameter at line 31 is used and does not need a suppression.

🧰 Tools
🪛 GitHub Check: JS SDK Lint

[warning] 20-20:
'query' is defined but never used

Source: Linters/SAST tools

Comment on lines +19 to +20
const { organization } = useContext(OrganizationContext);
const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include organization member loading in the returned loading state.

If listProjectUsers resolves before useOrgMembersMap, eligibleMembers is empty while isLoading is false. The member picker can display an incorrect empty state.

Proposed fix
-  const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id);
+  const {
+    data: orgMembersMap = {},
+    isLoading: isOrgMembersMapLoading,
+  } = useOrgMembersMap(organization?.id);
...
-    isLoading,
+    isLoading: isLoading || isOrgMembersMapLoading,

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 31441247261

Coverage remained the same at 48.097%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39919
Covered Lines: 19200
Line Coverage: 48.1%
Coverage Strength: 15.37 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Do not merge Label to indicate that the PR is not ready to be merged even though might be (or not) approvals.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants