Skip to content

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

Open
Shreyag02 wants to merge 27 commits into
mainfrom
fix/admin-duplicate-api-calls
Open

fix(admin): stop duplicate API calls across admin tables#1869
Shreyag02 wants to merge 27 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 fetched its first page twice, and the org detail page could fetch its active tab up to four times — visible on staging as (canceled) requests.

Our tables pass defaultSort to DataTable but leave sort out of the initial query. DataTable merges defaultSort in and emits it on mount, changing the connect-query cache key and triggering a second request; the first is aborted mid-flight, after the server has already done the work.

Scoped to duplicate and redundant requests only.

Changes

Change What was happening Scope
Seed the initial query with sort The mount emit changed the request, so every table fetched page 1 twice All 11 server tables
Drop the empty defaultSort on project members Sent a sort with an empty field name; that endpoint ignores sort Project members dialog
Keep the org detail tab mounted while billing loads The tab unmounted and remounted mid-load, replaying every request in it details/index.tsx
Cover both billing legs in isBillingAccountLoading The flag missed the listBillingAccounts leg, so the side panel showed empty fallbacks as settled Org context, side panel, edit billing, add tokens
Latch "load more" against scroll bursts Repeat calls cancel the in-flight page and gain nothing All 11 server tables
Scope the members invalidation to its own org An empty input matched partially, invalidating every org's member list Members tab
Give the shared reads a staleTime staleTime: 0 + refetchOnMount refetched reference data on every navigation getOrganization, roles, org member map
Reuse the org resolved from a slug URL The same org was fetched again by id under a different cache key Org detail page
Share the query state and load-more latch The latch was copied into all 11 tables and had drifted; query state came in three shapes useServerTableQuery, useLoadMore
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
Gate the invite dialog's queries on open Its trigger sits in the navbar, so two queries ran on every visit Users list
Disable Add tokens and Save without a billing account Both submit guards return silently, so the buttons looked live and did nothing Add tokens, Edit billing
Log member-map fetch failures again Moving the query into a hook dropped the provider's console.error Projects tab

Why

Topic Mechanism
Why the key changes createMessageKey omits unset fields, so sort: [] and sort: [{name: "created_at", …}] hash differently — one query in our heads, two cache entries. Seeding the initial sort makes the mount emit structurally identical to state, so the key never changes.
Why the tab remounted The gate included isBillingAccountLoading but not the listBillingAccounts call that enables it, and a disabled query reports isLoading: false. So it ran truefalse (tab mounts, tables fetch) → true (unmounts) → false (remounts, tables refetch). It now only includes queries enabled from the first render, so it flips once.
What that exposed The same flag was wrong for its own consumers: the side panel rendered N/A and 0 / Prepaid as settled, and both billing submits became reachable before the ids existed. Guards alone weren't enough — with billing settled and no account the flag is false, so the buttons enabled and the guards swallowed the click. Both now disable on the condition their guard rejects.
Why a guard wasn't enough for load-more hasNextPage / isFetchingNextPage are last-render values and react-query notifies on a macrotask, but VirtualizedContent calls onLoadMore straight from onScroll — a burst clears the guard before React re-renders, and fetchNextPage defaults to cancelRefetch: true, aborting the in-flight page. Only a synchronously-flipped ref closes it.
Seed + staleTime only work together Not seeded + 30s → 1 request. Seeded + staleTime: 0 → 1. Seeded + 30s → 0. The seed writes only into an empty key: slug- and id-keyed entries are separate and an edit invalidates only the id-keyed one, so an unconditional seed could overwrite fresher data.
staleTime is opt-in A client-wide default would make correctness ride on every mutation invalidating what it touches. SHARED_QUERY_STALE_TIME is applied to the four queries that need it; everything else keeps refetchOnMount. The member map had no writer, so the members tab now invalidates listOrganizationUsers too.
Seeding runs in a layout effect The view mounts in the same commit and subscribes from a passive effect, which runs after every layout effect — so the seed lands in time while staying out of the render path. A plain useEffect costs the extra GetOrganization back.
What sharing the hooks changed The org and user lists debounced the table state, so the grid lagged behind typing; only the request is debounced now, and top-level invoices gains the debounce it never had. The apis tab now resets offset on query change, as the other ten did. Audit logs still publishes its request for the CSV export, now from an effect. useLoadMore takes the union of the two drifted latches, so neither half regresses.

Merged main

One conflict in details/index.tsx: main replaced the inline role queries with useOrganizationRoles, this branch removed the member map. Both kept, isLoading combined as isOrganizationLoading || isRolesLoading. main's new invites table needs nothing here — it's mode="client", so Apsara skips the mount emit, and it has no load-more path.

Test Plan

  • Build, type check and lint pass
  • Request counts measured on the production bundle
  • A pass against a real backend — see below
Check Result
pnpm build in web/sdk and web/apps/admin Succeed
tsc --noEmit Same 21 pre-existing errors before and after; none in touched files. web/apps/admin clean
go build ./..., go vet after the merge Clean
eslint on all changed files 0 errors; only pre-existing warnings
Every mode="server" table audited 11 in web/sdk/admin, all covered; each pairs seeded sort with defaultSort, project members correctly has neither
Cache key, real Apsara + connect-query Keys differ before the change, match after
Seed timing, React 19.2.4 + react-query 5.90.21 0 fetches for a layout seed, 1 for a passive one — same under StrictMode
useLoadMore, real hook in jsdom Burst of 10 → 1 fetch; latch releases; all three early-outs hold; a rejected fetch doesn't wedge it

Production bundle in headless Chrome — every page issued each RPC exactly once, none cancelled:

Page RPCs
/organizations cold 4 + configs = 5, none cancelled (was 6, with a cancelled SearchOrganizations)
/users, /audit-logs, /invoices 3 each
Org detail cold via slug 10 — exactly one GetOrganization, confirming the seed
Org detail → members / projects / pat 10 each
Org detail → apis / invoices / security / tokens 9 each
Org list search, 4 keystrokes 1 SearchOrganizations, none cancelled

Ran against a stub backend, so tables were empty: real data, latencies and error responses aren't covered, and browser-level load-more wasn't exercised — the hook test above covers the latch instead.

Still worth a click-through:

# Check Expected
1 Fast-scroll load-more on a table with real rows One request per page, none cancelled
2 Sort, filter and infinite scroll on each table One request per change
3 Projects tab Member avatars render; add-members dropdown still filters
4 Org side panel during load Skeletons, not N/A / 0 / Prepaid
5 Audit logs → export CSV without touching a filter Downloads, and now respects on-screen filters; previously sent an empty request

Out of scope

Issue Detail
GetOrganization 403 on the admins list Not a duplicate-call artifact. app/organization#get grants superusers access only via platform->superuser, which needs the org's platform relation tuple — written once at creation, no backfill. OrgCell issues one per service-user row (views/admins/columns.tsx:74-78); the row falls back to the raw id with its button disabled, so nothing breaks.
Edit KYC shows a verified org as unverified Pre-existing, but narrowing the loading gate makes it easier to hit — and saving writes the verification away. Fixed separately by the one-hop KYC query.

@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 24, 2026 8:27am

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c8281b3-1a31-4207-bab6-efcea9d430b7

📥 Commits

Reviewing files that changed from the base of the PR and between 2c185b3 and 28da34a.

📒 Files selected for processing (1)
  • web/sdk/admin/views/organizations/details/edit/billing.tsx

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added consistent server-side search, sorting, pagination, and loading across administration lists.
    • Added reusable organization member lookup and safer “load more” handling.
    • Invitation-related data now loads only when the dialog opens.
  • Bug Fixes

    • Improved billing and token loading states on organization details.
    • Prevented billing updates when required account information is missing.
    • Improved cached organization data refresh behavior.

Walkthrough

The PR adds shared server-table query and pagination hooks, moves organization member data into a dedicated query hook, updates query cache freshness and priming, and revises organization billing and invitation loading conditions.

Changes

Admin query and organization data updates

Layer / File(s) Summary
Query freshness and organization cache
web/sdk/admin/utils/constants.ts, web/sdk/admin/hooks/useOrganizationRoles.ts, web/sdk/admin/views/organizations/details/index.tsx, web/apps/admin/src/pages/organizations/details/index.tsx
Adds shared query staleness settings. Organization cache priming now runs in a layout effect.
Organization member data ownership
web/sdk/admin/hooks/useOrgMembersMap.ts, web/sdk/admin/views/organizations/details/contexts/organization-context.tsx, web/sdk/admin/views/organizations/details/projects/*, web/sdk/admin/views/organizations/details/members/index.tsx
Adds organization member-map querying and removes member-map values from organization context. Member invalidation refreshes both organization-scoped query forms.
Shared server-table and pagination flow
web/sdk/admin/hooks/useServerTableQuery.ts, web/sdk/admin/hooks/useLoadMore.ts, web/sdk/admin/views/audit-logs/index.tsx, web/sdk/admin/views/invoices/index.tsx, web/sdk/admin/views/organizations/*, web/sdk/admin/views/users/list/list.tsx
Centralizes table-query state, RQL transformation, debouncing, pagination reset, and guarded infinite-query loading across admin tables.
Conditional queries and billing loading states
web/sdk/admin/views/organizations/details/index.tsx, web/sdk/admin/views/organizations/details/edit/billing.tsx, web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx, web/sdk/admin/views/organizations/details/side-panel/*, web/sdk/admin/views/users/list/invite-users.tsx
Combines billing account and detail loading states, guards billing updates without identifiers, changes token-dialog loading behavior, and gates invitation queries by dialog visibility.

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

Merge Risk: 🔵 Low · up to 28da3

The PR reduces duplicate admin requests and prevents billing actions from appearing usable when required account data is unavailable. One bounded merge-readiness risk remains: organization data resolved from a slug may refresh a separate ID-based cache entry’s freshness timestamp, potentially delaying visibility of later organization changes; mergeable with explicit owner awareness or follow-up.

Suggested reviewers: rohanchkrabrty

🚥 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

Comment thread web/sdk/admin/hooks/useServerTableQuery.ts
Comment on lines +19 to +20
const { organization } = useContext(OrganizationContext);
const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id);

@coderabbitai coderabbitai Bot Aug 10, 2026

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,

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.

Real, but not blocking — leaving it for follow-up rather than fixing here.

The window is narrower than it looks. The projects tab calls useOrgMembersMap itself on mount (projects/index.tsx:94), and useAddProjectMembers runs per row from columns.tsx:118, so those observers mount together and share one in-flight fetch. Hitting the wrong empty state needs the dropdown opened inside the gap where listProjectUsers has resolved but listOrganizationUsers has not — so a click, on a cold cache, on a slow connection.

54759942 also gave useOrgMembersMap a staleTime, so anything that has already loaded the projects tab serves from cache and the window closes entirely.

Queuing it on fix/admin-ui-followups instead of growing a PR that is already carrying a refactor.

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.

@Shreyag02, acknowledged. The shared in-flight query and staleTime reduce the exposure window.

The loading-state gap remains valid on a cold cache. Deferring it keeps this refactor scoped appropriately.

Would you like me to open a GitHub follow-up issue for fix/admin-ui-followups?

You are interacting with an AI system.

@coveralls

coveralls commented Aug 10, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32706398253

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.08%) to 48.886%

Details

  • Coverage increased (+0.08%) from 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: 40034
Covered Lines: 19571
Line Coverage: 48.89%
Coverage Strength: 15.76 hits per line

💛 - Coveralls

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.
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.
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.
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.
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.
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.
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.
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.
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.
@Shreyag02 Shreyag02 removed 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 17, 2026
@Shreyag02
Shreyag02 marked this pull request as ready for review August 17, 2026 16:26

@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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b1080dc-9c22-45e9-bb04-474ed5412152

📥 Commits

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

📒 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/views/audit-logs/index.tsx
  • 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/edit/billing.tsx
  • web/sdk/admin/views/organizations/details/index.tsx
  • web/sdk/admin/views/organizations/details/invoices/index.tsx
  • web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.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/side-panel/billing-details-section.tsx
  • web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx
  • web/sdk/admin/views/organizations/details/tokens/index.tsx
  • web/sdk/admin/views/organizations/list/index.tsx
  • web/sdk/admin/views/users/list/invite-users.tsx
  • web/sdk/admin/views/users/list/list.tsx
💤 Files with no reviewable changes (1)
  • web/sdk/admin/views/organizations/details/contexts/organization-context.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • web/sdk/admin/hooks/useOrgMembersMap.ts
  • web/apps/admin/src/contexts/ConnectProvider.tsx
  • web/apps/admin/src/pages/organizations/details/index.tsx
  • web/sdk/admin/views/organizations/details/projects/index.tsx
  • web/sdk/admin/views/organizations/details/index.tsx
  • web/sdk/admin/views/organizations/list/index.tsx

Included review availability: Your plan includes up to 2 reviews per rolling hour; 1 remains after this review.

Comment thread web/sdk/admin/views/organizations/details/edit/billing.tsx Outdated
The submit guard added earlier returns silently when the org has no billing
account, but isBillingAccountLoading settles to false in that case, so the
Add button stays enabled. The click did nothing at all: the dialog stayed
open with no toast and no console output.

Hoist the guard condition into canCheckout and feed it to the button's
disabled prop, so the state the guard rejects is no longer reachable.
Moving listOrganizationUsers out of the details provider and into
useOrgMembersMap dropped the console.error that went with it. A failed fetch
then left the projects tab rendering raw user ids in place of member avatars
with no signal anywhere, not even in the console.

Read error off the hook in the projects view and log it there, keeping the
hook a plain query wrapper and the message in line with its siblings in
details/index.tsx.
The one conflict was organizations/details/index.tsx, where both sides
reworked the same block of the provider.

- roles: take main's useOrganizationRoles hook and drop our two inline role
  queries. The hook logs both role errors itself, so removing them from the
  provider's error effect loses no logging.
- members: keep our removal. main still fetched listOrganizationUsers here;
  that query and its error log now live in useOrgMembersMap and the projects
  view, which are its only readers.
- isLoading: main's single isRolesLoading flag, still without the billing leg
  we dropped to stop the tab remounting mid-load.

@rohilsurana rohilsurana left a comment

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.

Review against main. Solid fix and well tested. Two broader risks worth a look (staleTime, render-phase cache write) and three smaller notes.

queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: 30 * 1000,

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.

A 30s app-wide staleTime means every read that does not opt out serves cached data for up to 30s after navigation, so correctness now rides on every mutation invalidating or setting the keys it touches. That is easy to miss. For example, creating an invite from the users list does not invalidate an org's listOrganizationInvitations, so it can show stale for 30s. Worth stating that convention clearly, or scoping the longer staleTime to just the queries that want it.

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.

Are we ok with this in long term?

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.

No, we weren't — you were right, and it's gone. ConnectProvider.tsx is back to exactly what's on main, no client-wide staleTime.

It's opt-in per query now, via SHARED_QUERY_STALE_TIME, applied to the four reads that were actually relying on the default:

  • getOrganization by id — the slug-resolve seed is worthless without it (seeded + staleTime: 0 is still 1 request); edit and block/unblock both invalidate that key
  • both legs of useOrganizationRoles — reference data, and nothing in the console writes roles
  • useOrgMembersMap — one observer per project row plus the project members page

Everything else keeps refetchOnMount, so your listOrganizationInvitations example can't go stale.

The convention is stated where you'd read it before using it, in constants.ts: only safe where every writer invalidates the key, so each query opts in and says why. That precondition wasn't holding for the member map — nothing invalidated it — so the members tab now invalidates listOrganizationUsers alongside its own table. Without that, a member removed there would have shown for 30s in the projects tab.

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.

schema: AdminServiceQueries.searchOrganizationUsers,
transport,
input: {},
input: { id: organizationId },

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.

If organizationId is ever an empty string, createConnectQueryKey drops the empty id field and the input collapses back to {}, which partial-matches and re-invalidates every org's member list, the exact bug this line fixes. It is guarded by the member-action preconditions today, but a non-empty-id guard here would make it safe by construction.

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.

Guarded — invalidateMembersQuery now returns early on an empty id.

It stopped being optional: scoping the staleTime meant useOrgMembersMap needed a writer, so this function gained a second invalidateQueries for listOrganizationUsers. Adding a second partial-match invalidation while relying on "the member-action preconditions hold today" would have doubled exactly the hazard you named.

Confirmed the mechanism rather than assuming it — message-key.js says it outright: "Default values are omitted (both implicit and explicit field presence)". "" is the proto3 default for a string, so { id: "" } really does collapse to input: {} and prefix-matches every org.

Both invalidations sit behind the one guard, and updateMember / removeMember are the only callers, so it's safe by construction now.

Four other sites build org-scoped keys the same way without a guard — remove-invite-dialog.tsx, invite-users-dialog.tsx, domains-list.tsx, sessions/index.tsx. The last two are worse in principle, since they write || "" explicitly. None is touched by this PR, so I've left them rather than fix in passing.

data-test-id="add-tokens-invite-button"
type="submit"
loaderText="Adding..."
disabled={isBillingAccountLoading || !canCheckout || isSubmitting}

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.

isBillingAccountLoading is redundant here. While billing loads, billingAccountId is empty so canCheckout is already false and the button is disabled. The extra term does not change the outcome.

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.

Agreed, dropped. disabled={!canCheckout || isSubmitting}.

isLoading is isPending && isFetching, so it's only true when there's no data at all — which means billingAccount is undefined, billingAccountId is "", and !canCheckout has already disabled the button. There's no state where the term flipped the result: a cached-data refetch surfaces as isFetching, not isLoading, so it can't produce a truthy flag alongside a real account either.

It was this file's only reader of the flag, so it came off the context destructure too. The flag itself stays — edit/billing.tsx and both side-panel sections still use it.

Related, from the same review pass: Edit billing had the mirror-image bug and is now fixed the same way. The description had claimed that panel was unreachable without a billing account; it isn't — Edit… → Billing… (layout/navbar.tsx:157) is ungated, so Save sat live over a guard that returns silently. Both panels now disable on the condition their guard rejects.

A client-wide default made every read serve cached data for 30s unless it
opted out, so correctness rode on every mutation invalidating the keys it
touches. That is easy to miss, and wrong for reads that move quickly — an
org's invitations among them.

Drop the default and opt in per query, for the four that relied on it:

- getOrganization by id, so the page's slug-resolve seed stands instead of
  being refetched; edit and block/unblock both invalidate that key
- both legs of useOrganizationRoles, read from the members, projects and
  invite surfaces, and nothing in the console writes roles
- useOrgMembersMap, one observer per project row plus the project members
  page

The member map had no writer invalidating it, so the members tab now
invalidates that key alongside its own table, guarded against an empty org
id that would otherwise partial-match every org.

Everything else keeps react-query's refetch-on-mount.
isLoading is only true while there is no data, so billingAccount is
undefined in that window and canCheckout is already false. The extra term
never changed whether the button was disabled.

It was this file's only reader of the flag, so drop it from the context
destructure too.
Writing to the query cache in the render body notifies observers from a
phase that is not allowed to, and a render React discards still mutates the
global cache.

A layout effect fixes both and keeps the request saving: the view mounts in
this same commit and subscribes to that key from a passive effect, which
runs after every layout effect, so the seed is still in place by the time it
would otherwise refetch. A passive effect here would land too late and cost
the extra GetOrganization back.

The ref latch goes with it — the dependency array and the existing
empty-key check already cover repeats.
The load-more latch was copied into all eleven server tables and the
copies had already drifted: six guarded on isError but swallowed the
failure, five logged it but retried on every further scroll. useLoadMore
takes the union, so neither half regresses.

useServerTableQuery comes from fix/admin-ui-followups unchanged, so this is
the hook that branch already carries rather than a second one. It absorbs
three different query-state shapes:

- six org tabs held state, a memo and a debounce by hand
- the org, user and invoice lists debounced the table state itself, so the
  grid lagged behind typing; only the request is debounced now, and the
  invoice list gains the debounce it never had
- audit logs and project members kept query and rqlRequest in one object;
  audit logs still publishes its request for the navbar's CSV export, now
  from an effect, and project members drops sort through mapQuery

The apis tab also starts resetting offset when the query changes, which the
other ten already did.

Eleven views, 223 lines lighter.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/apps/admin/src/pages/organizations/details/index.tsx (1)

91-105: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the source query timestamp when priming the ID key.

setQueryData assigns the current time to dataUpdatedAt by default. Pass the slug query’s dataUpdatedAt through the updatedAt option so the ID-keyed query does not extend stale data for 30 seconds.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: be19e777-2ef0-4d3f-be3c-b9371cb106df

📥 Commits

Reviewing files that changed from the base of the PR and between 2cfe257 and 2c185b3.

📒 Files selected for processing (19)
  • web/apps/admin/src/pages/organizations/details/index.tsx
  • web/sdk/admin/hooks/useLoadMore.ts
  • web/sdk/admin/hooks/useOrgMembersMap.ts
  • web/sdk/admin/hooks/useOrganizationRoles.ts
  • web/sdk/admin/hooks/useServerTableQuery.ts
  • web/sdk/admin/utils/constants.ts
  • web/sdk/admin/views/audit-logs/index.tsx
  • web/sdk/admin/views/invoices/index.tsx
  • web/sdk/admin/views/organizations/details/apis/index.tsx
  • web/sdk/admin/views/organizations/details/index.tsx
  • web/sdk/admin/views/organizations/details/invoices/index.tsx
  • web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.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/tokens/index.tsx
  • web/sdk/admin/views/organizations/list/index.tsx
  • web/sdk/admin/views/users/list/list.tsx

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.

The panel opens from the navbar's Edit menu, which is ungated, so an org
with no billing account can reach it. Both ids come from billingAccount, so
the submit guard returned silently while Save still looked live — the same
dead click already fixed on Add tokens.

Disable on the condition the guard rejects, and name it so the two panels
read the same way.
Left behind when the shared hook replaced this file's INITIAL_QUERY; the
type had no other reference here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants