diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 274cf83ed..3bac81a72 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -13,6 +13,12 @@ import { fetchAssignmentPaymentSplits } from '../../services/payments.service' import BillingAccountLineItemsModal from './BillingAccountLineItemsModal' +jest.mock('./billing-detail-requests', () => ({ + loadBillingDetails: (values: string[], lookup: (value: string) => Promise) => ( + Promise.all(values.map(lookup)) + ), +})) + jest.mock('../../../config/routes.config', () => ({ rootRoute: '/work', })) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 52d8e9486..c7bfece22 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -37,6 +37,7 @@ import { getCopilotMemberPaymentsBudgetInfo, } from '../../utils/project-billing-account.utils' +import { loadBillingDetails } from './billing-detail-requests' import styles from './BillingAccountLineItemsModal.module.scss' type SortField = 'amount' | 'status' | 'date' @@ -566,7 +567,7 @@ function getEngagementPaymentAssignmentIds(items: BillingAccountLineItem[]): str async function fetchChallengeDetailsById( challengeIds: string[], ): Promise { - const entries = await Promise.all(challengeIds.map(async challengeId => { + const entries = await loadBillingDetails(challengeIds, async challengeId => { try { const challenge = await fetchChallenge(challengeId) @@ -574,7 +575,7 @@ async function fetchChallengeDetailsById( } catch { return undefined } - })) + }) return new Map( entries.filter((entry): entry is readonly [string, Challenge] => !!entry), @@ -586,13 +587,14 @@ async function fetchChallengeDetailsById( * * @param assignmentIds Engagement assignment ids referenced by consumed line items. * @returns Map keyed by assignment id with finance payment rows. - * @remarks Individual assignment failures are ignored so billing-account + * @remarks Requests share a paced queue with challenge hydration. + * Individual assignment failures are ignored so billing-account * details still render with existing fallback values. */ async function fetchAssignmentPaymentsById( assignmentIds: string[], ): Promise { - const entries = await Promise.all(assignmentIds.map(async assignmentId => { + const entries = await loadBillingDetails(assignmentIds, async assignmentId => { try { const payments = await fetchAssignmentPaymentSplits(assignmentId) @@ -600,7 +602,7 @@ async function fetchAssignmentPaymentsById( } catch { return [assignmentId, [] as AssignmentPayment[]] as const } - })) + }) return new Map(entries) } @@ -1014,8 +1016,9 @@ export const BillingAccountLineItemsModal: FC : undefined, () => fetchChallengeDetailsById(challengeLineItemIds), { - errorRetryCount: 2, - shouldRetryOnError: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, }, ) const challengeDetailsById = challengeLineItemIds.length > 0 @@ -1031,8 +1034,9 @@ export const BillingAccountLineItemsModal: FC : undefined, () => fetchAssignmentPaymentsById(engagementPaymentAssignmentIds), { - errorRetryCount: 2, - shouldRetryOnError: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, }, ) const assignmentPaymentsById = engagementPaymentAssignmentIds.length > 0 diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.spec.ts b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.spec.ts new file mode 100644 index 000000000..adc06f18c --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.spec.ts @@ -0,0 +1,39 @@ +import { loadBillingDetails } from './billing-detail-requests' + +describe('billing detail request queue', () => { + it('paces concurrent challenge and finance batches through the same queue', async () => { + const starts: number[] = [] + let active = 0 + let peak = 0 + const lookup = async (value: string): Promise => { + starts.push(Date.now()) + active += 1 + peak = Math.max(peak, active) + await Promise.resolve() + active -= 1 + return value + } + + const [challenges, payments] = await Promise.all([ + loadBillingDetails(['challenge-1', 'challenge-2'], lookup), + loadBillingDetails(['assignment-1'], lookup), + ]) + expect(challenges) + .toEqual(['challenge-1', 'challenge-2']) + expect(payments) + .toEqual(['assignment-1']) + expect(peak) + .toBe(1) + expect(starts[1] - starts[0]) + .toBeGreaterThanOrEqual(450) + expect(starts[2] - starts[1]) + .toBeGreaterThanOrEqual(450) + }) + + it('releases the queue after a failed request', async () => { + await expect(loadBillingDetails(['missing'], async () => { + throw new Error('Unavailable') + })).rejects.toThrow('Unavailable') + await expect(loadBillingDetails(['next'], async value => value)).resolves.toEqual(['next']) + }) +}) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.ts b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.ts new file mode 100644 index 000000000..4cf5c27a5 --- /dev/null +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/billing-detail-requests.ts @@ -0,0 +1,26 @@ +const REQUEST_INTERVAL_MS = 500 +let nextRequest: Promise = Promise.resolve() + +/** + * Schedules billing detail lookups through one shared queue so opening a large + * account cannot burst hundreds of requests at the challenge and finance APIs. + * Each request settles before the next begins, with a half-second gap. A failed + * request releases the queue; the caller decides which row fallback to display. + * @param values Unique identifiers to hydrate, in display-independent order. + * @param fetchDetail Asynchronous lookup for one identifier. + * @returns Results in input order after all lookups settle successfully. + * @throws Propagates lookup errors without blocking subsequent queued requests. + */ +export async function loadBillingDetails( + values: string[], + fetchDetail: (value: string) => Promise, +): Promise { + return Promise.all(values.map(value => { + const request = nextRequest.then(() => fetchDetail(value)) + nextRequest = request.catch(() => undefined) + .then(() => new Promise(resolve => { + setTimeout(resolve, REQUEST_INTERVAL_MS) + })) + return request + })) +}