-
Notifications
You must be signed in to change notification settings - Fork 45
fix(admin): stop duplicate API calls across admin tables #1869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Shreyag02
wants to merge
27
commits into
main
Choose a base branch
from
fix/admin-duplicate-api-calls
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 85174f4
fix(admin): drop the empty default sort on project members
Shreyag02 35ec779
fix(admin): keep the org detail tab mounted while billing loads
Shreyag02 968f603
fix(admin): stop fast scrolling firing redundant page requests
Shreyag02 c8db582
fix(admin): scope the members invalidation to its organization
Shreyag02 75bc15c
fix(admin): give queries a default staleTime
Shreyag02 c101560
fix(admin): reuse the resolved org instead of refetching it by id
Shreyag02 c0a73c5
fix(admin): fetch the org member map only where it is used
Shreyag02 7a9491e
fix(admin): fetch invite dialog options only when it opens
Shreyag02 e39a065
fix(admin): guard the last three load-more handlers
Shreyag02 cec34fa
docs(admin): tighten the comments added in this branch
Shreyag02 31015f5
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 b5d4a93
fix(admin): latch load-more against scroll bursts
Shreyag02 b86af87
fix(admin): cover both billing legs in isBillingAccountLoading
Shreyag02 b2509ec
fix(admin): seed the resolved org only into an empty cache key
Shreyag02 5ff0902
docs(admin): keep only the load-bearing comments
Shreyag02 b0e8108
fix(admin): disable Add tokens when there is no billing account
Shreyag02 79c7f9e
fix(admin): log member map failures again
Shreyag02 2cfe257
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 b11d0d9
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 bbd845a
Merge branch 'main' into fix/admin-duplicate-api-calls
Shreyag02 5475994
fix(admin): scope the staleTime to the queries that want it
Shreyag02 5d85db0
fix(admin): drop the redundant loading term on Add tokens
Shreyag02 2280197
fix(admin): seed the resolved org from an effect, not during render
Shreyag02 2c185b3
refactor(admin): share the server table query state and load-more latch
Shreyag02 28da34a
fix(admin): disable Save on Edit billing without a billing account
Shreyag02 ab39bd2
refactor(admin): drop the orphaned DataTableQuery import
Shreyag02 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
useEffectlands 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:Measured against real React 19.2.4 + react-query 5.90.21, identical under
StrictMode. So auseEffecthere would have quietly given back theGetOrganizationthis 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
useReflatch 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.