From 81d033ff2237d06b17970edb7d6303687715950c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 23:57:49 +0200 Subject: [PATCH 1/2] refactor(frontend): consolidate account-overrides prefetch helper (closes #198) Extract fetchOverridesForAccounts() into frontend/src/lib/overrides.ts, replacing two near-identical inline Promise.all blocks in openFanOutModal and openPurchaseModal. Update the mixed-SP DOM test for the extra async boundary introduced by the new function. --- .../src/__tests__/recommendations.test.ts | 5 +++- frontend/src/lib/overrides.ts | 23 ++++++++++++++++ frontend/src/recommendations.ts | 26 +++---------------- 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/overrides.ts diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index f95dfc281..7171caf2c 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -3917,7 +3917,10 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { await loadRecommendations(); (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + // fetchOverridesForAccounts is a separate async function (one extra + // microtask boundary vs the previous inline Promise.all); four ticks + // are needed for openFanOutModal to finish populating the DOM. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // The fan-out modal renders one section per bucket; the SP section // title carries the combined plan-type label. diff --git a/frontend/src/lib/overrides.ts b/frontend/src/lib/overrides.ts new file mode 100644 index 000000000..64cb1c239 --- /dev/null +++ b/frontend/src/lib/overrides.ts @@ -0,0 +1,23 @@ +import * as api from '../api'; +import type { AccountServiceOverride } from '../api/accounts'; + +/** + * Fetches AccountServiceOverride arrays for a set of account IDs in parallel. + * Returns a Map keyed by account ID. Per-account fetch errors are silently + * swallowed so callers can always fall back to their default seed. + */ +export async function fetchOverridesForAccounts( + ids: Iterable, +): Promise> { + const out = new Map(); + await Promise.all( + Array.from(ids).map(async (id) => { + try { + out.set(id, await api.listAccountServiceOverrides(id)); + } catch { + // Silent fallback -- callers handle missing entries gracefully. + } + }), + ); + return out; +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 9b9f2c5d3..f5f4fd4da 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -31,6 +31,7 @@ function canActOnRecommendations(): boolean { return canAccess('execute', 'purchases') || canAccess('create', 'plans'); } import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; +import { fetchOverridesForAccounts } from './lib/overrides'; // Re-export the shared primitives so existing consumers that import from // recommendations.ts keep working without import-path churn (issue #166). export { parseNumericFilter } from './lib/column-filters'; @@ -4130,18 +4131,7 @@ async function openFanOutModal( if (r.cloud_account_id) allAccountIDs.add(r.cloud_account_id); } } - const overridesByAccount = new Map(); - await Promise.all( - Array.from(allAccountIDs).map(async (id) => { - try { - const list = await api.listAccountServiceOverrides(id); - overridesByAccount.set(id, list); - } catch { - // Silent fallback to toolbar seed — a network blip shouldn't - // block the user from purchasing. - } - }), - ); + const overridesByAccount = await fetchOverridesForAccounts(allAccountIDs); const buckets: FanOutBucket[] = bucketEntries .filter(([_key, recs]) => recs.length > 0) @@ -4891,17 +4881,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[]): for (const r of currentPurchaseRecommendations) { if (r.cloud_account_id) accountIDs.add(r.cloud_account_id); } - const overridesByAccount = new Map(); - await Promise.all( - Array.from(accountIDs).map(async (id) => { - try { - const list = await api.listAccountServiceOverrides(id); - overridesByAccount.set(id, list); - } catch { - // Silent fallback to rec-own / paymentOptionsFor[0] seed. - } - }), - ); + const overridesByAccount = await fetchOverridesForAccounts(accountIDs); // Compute seed per rec and mutate currentPurchaseRecommendations in // place so the in-flight modal state matches what the dropdowns From 0a3e762d22b85573d424d7f262c97e47dc302ad3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 23:35:26 +0200 Subject: [PATCH 2/2] test(frontend): fix missing microtask ticks for openFanOutModal tests fetchOverridesForAccounts adds one extra async boundary vs the old inline Promise.all; DOM-querying tests in #111 (issue j) and #132 (mixed-SP, umbrella-SP) need 4 Promise.resolve() ticks, not 3. --- .../src/__tests__/recommendations.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 7171caf2c..2032eba48 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -3553,7 +3553,10 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' await loadRecommendations(); (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + // fetchOverridesForAccounts is a separate async function (one extra + // microtask boundary vs the previous inline Promise.all); four ticks + // are needed for openFanOutModal to finish populating the DOM. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // Change the multi-account 1yr bucket's bucket-level Payment dropdown. const bucketSelects = Array.from( @@ -3853,8 +3856,10 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { await loadRecommendations(); (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); - // openFanOutModal is async (issue #111 prefetch); wait a tick. - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + // fetchOverridesForAccounts is a separate async function (one extra + // microtask boundary vs the previous inline Promise.all); four ticks + // are needed for openFanOutModal to finish populating the DOM. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); const { getFanOutBuckets } = await import('../recommendations'); const buckets = getFanOutBuckets(); @@ -3949,7 +3954,10 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { await loadRecommendations(); (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + // fetchOverridesForAccounts is a separate async function (one extra + // microtask boundary vs the previous inline Promise.all); four ticks + // are needed for openFanOutModal to finish populating the DOM. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // The SP bucket section must contain a
element with the // plan-type breakdown (issue #249). @@ -3996,7 +4004,10 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { await loadRecommendations(); (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); - await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + // fetchOverridesForAccounts is a separate async function (one extra + // microtask boundary vs the previous inline Promise.all); four ticks + // are needed for openFanOutModal to finish populating the DOM. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); const spSections = Array.from(document.querySelectorAll('.fanout-bucket')).filter( (s) => s.querySelector('h4')?.textContent?.includes('Savings Plans'),