fix(economic): fix national debt panel hang + add lazy pagination (#1928)

- Remove persistCache from nationalDebtBreaker: IndexedDB hydration on
  first call can deadlock in some browsers, causing the panel to hang
  indefinitely showing "Loading debt data from IMF..." with no timeout
- Add 20s hard deadline via Promise.race so loading always resolves
- Extract _fetchNationalDebt() to avoid top-level await in the race
- Lazy-load 20 countries initially with "Load more" button (+20 each tap)
  instead of rendering all 187 rows at once; resets on sort/search change
- Ticker only updates visible rows (not all 187)
This commit is contained in:
Elie Habib
2026-03-20 17:11:41 +04:00
committed by GitHub
parent 7711e9de03
commit c6099d785a
3 changed files with 58 additions and 5 deletions

View File

@@ -101,6 +101,8 @@ function sortEntries(entries: NationalDebtEntry[], mode: SortMode): NationalDebt
return sorted;
}
const PAGE_SIZE = 20;
export class NationalDebtPanel extends Panel {
private entries: NationalDebtEntry[] = [];
private filteredEntries: NationalDebtEntry[] = [];
@@ -108,6 +110,7 @@ export class NationalDebtPanel extends Panel {
private searchQuery = '';
private loading = false;
private lastFetch = 0;
private visibleCount = PAGE_SIZE;
private tickerInterval: ReturnType<typeof setInterval> | null = null;
private readonly REFRESH_INTERVAL = 6 * 60 * 60 * 1000;
@@ -120,12 +123,20 @@ export class NationalDebtPanel extends Panel {
});
this.content.addEventListener('click', (e) => {
const tab = (e.target as HTMLElement).closest('[data-sort]') as HTMLElement | null;
const target = e.target as HTMLElement;
const tab = target.closest('[data-sort]') as HTMLElement | null;
if (tab?.dataset.sort) {
this.sortMode = tab.dataset.sort as SortMode;
this.visibleCount = PAGE_SIZE;
this.applyFilters();
this.render();
this.restartTicker();
return;
}
if (target.closest('.debt-load-more')) {
this.visibleCount += PAGE_SIZE;
this.render();
this.restartTicker();
}
});
@@ -133,6 +144,7 @@ export class NationalDebtPanel extends Panel {
const target = e.target as HTMLInputElement;
if (target.classList.contains('debt-search')) {
this.searchQuery = target.value;
this.visibleCount = PAGE_SIZE;
this.applyFilters();
this.render();
this.restartTicker();
@@ -227,8 +239,13 @@ export class NationalDebtPanel extends Panel {
<input class="debt-search" type="text" placeholder="Search country..." value="${escapeHtml(this.searchQuery)}">
</div>
<div class="debt-list">
${this.filteredEntries.slice(0, 100).map((entry, idx) => this.renderRow(entry, idx + 1)).join('')}
${this.filteredEntries.slice(0, this.visibleCount).map((entry, idx) => this.renderRow(entry, idx + 1)).join('')}
</div>
${this.visibleCount < this.filteredEntries.length ? `
<button class="debt-load-more">
Load ${Math.min(PAGE_SIZE, this.filteredEntries.length - this.visibleCount)} more
<span class="debt-load-more-count">(${this.filteredEntries.length - this.visibleCount} remaining)</span>
</button>` : ''}
<div class="debt-footer">
<span class="debt-source">Source: IMF WEO 2024 + US Treasury FiscalData</span>
<span class="debt-updated">Updated: ${new Date(this.lastFetch).toLocaleDateString()}</span>
@@ -279,7 +296,7 @@ export class NationalDebtPanel extends Panel {
}
const container = this.content.querySelector('.debt-list');
if (!container) return;
for (const entry of this.filteredEntries.slice(0, 100)) {
for (const entry of this.filteredEntries.slice(0, this.visibleCount)) {
const el = container.querySelector<HTMLElement>(`.debt-ticker[data-iso3="${entry.iso3}"]`);
if (el) {
el.textContent = formatDebt(getCurrentDebt(entry));

View File

@@ -589,13 +589,25 @@ export type { NationalDebtEntry };
// National Debt Clock
// ========================================================================
const nationalDebtBreaker = createCircuitBreaker<GetNationalDebtResponse>({ name: 'National Debt', cacheTtlMs: 6 * 60 * 60 * 1000, persistCache: true });
// No persistCache: IndexedDB hydration on first call can deadlock in some browsers,
// causing the panel to hang indefinitely on "Loading debt data from IMF..."
const nationalDebtBreaker = createCircuitBreaker<GetNationalDebtResponse>({ name: 'National Debt', cacheTtlMs: 6 * 60 * 60 * 1000 });
const emptyNationalDebtFallback: GetNationalDebtResponse = { entries: [], seededAt: '', unavailable: true };
export async function getNationalDebtData(): Promise<GetNationalDebtResponse> {
const hydrated = getHydratedData('nationalDebt') as GetNationalDebtResponse | undefined;
if (hydrated?.entries?.length) return hydrated;
// Race all fetch paths against a hard 20s deadline so the panel never hangs.
return Promise.race([
_fetchNationalDebt(),
new Promise<GetNationalDebtResponse>(resolve =>
setTimeout(() => resolve(emptyNationalDebtFallback), 20_000),
),
]);
}
async function _fetchNationalDebt(): Promise<GetNationalDebtResponse> {
try {
const resp = await fetch(toApiUrl('/api/bootstrap?keys=nationalDebt'), {
signal: AbortSignal.timeout(5_000),
@@ -608,7 +620,7 @@ export async function getNationalDebtData(): Promise<GetNationalDebtResponse> {
try {
return await nationalDebtBreaker.execute(async () => {
return client.getNationalDebt({}, { signal: AbortSignal.timeout(15_000) });
return client.getNationalDebt({}, { signal: AbortSignal.timeout(12_000) });
}, emptyNationalDebtFallback);
} catch {
return emptyNationalDebtFallback;

View File

@@ -20264,6 +20264,30 @@ body.has-breaking-alert .panels-grid {
min-width: 70px;
}
.debt-load-more {
width: 100%;
padding: 8px;
margin: 4px 0;
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
text-align: center;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.debt-load-more:hover {
border-color: var(--accent);
color: var(--accent);
}
.debt-load-more-count {
opacity: 0.6;
}
.debt-footer {
display: flex;
justify-content: space-between;