Issue: Invoice-Liquidity-Network/ILN-Frontend#727
This document records the large-scale rendering audit of the key list/tabular pages and the actions taken to keep them responsive when a user has a mainnet-scale number of invoices (thousands to tens of thousands).
| Page | Rendered content | Bounding before | Status |
|---|---|---|---|
/marketplace |
Open invoice table | Already paginated, 20/page | OK |
/leaderboard |
LPs table | Already paginated, 20/page (top-50) | OK |
/stats |
Aggregated cards + chart | No unbounded DOM list (CPU-bound only; not addressed here) | OK |
/dashboard (timeline) |
Event timeline | "Load more" 20+20 | OK |
/dashboard (table mode) |
A freelancer's entire invoice set | Unbounded (every filtered/sorted row mounted) | Fixed |
/lp (discovery / watchlist) |
All matching/funded invoices | Unbounded | Fixed |
/dashboard table mode and the /lp discovery/watchlist table rendered every
matching invoice into the DOM at once. At mainnet scale this creates thousands
of table rows, which degrades initial render, layout, and scroll/interaction
performance and can stall the main thread.
Rather than add a virtualization dependency (e.g. react-window), both
unbounded lists now mount only a fixed-size window and reveal more on demand via
a "Load more" button. This bounds the DOM to a constant number of rows
regardless of list size, with negligible interaction cost, and keeps the initial
paint fast.
Introduced a shared hook, useVisibleWindow (src/hooks/useVisibleWindow.ts),
that both pages use:
- Renders only the first
pageSize(50) items. loadMore()reveals the nextpageSizeitems (a no-op once exhausted).- Resets to
pageSizewhenever the underlying data/filters/sort change, so the user sees results from the top again. - Emits
list:visible-windowanalytics (viasrc/lib/analytics.ts) recording{ list, visibleCount, total, hasMore }for every window change, giving a measurable signal of how much of a list is actually mounted.
src/hooks/useVisibleWindow.ts— new shared bounded-window hook + instrumentation.src/screens/Dashboard.tsx— table mode now rendersvisibleSlice(displayedInvoices)and a "Load more (N remaining)" button in the table footer.src/components/LPDashboard.tsx— discovery/watchlist tables render a bounded window and a "Load more (N remaining)" button.src/hooks/__tests__/useVisibleWindow.test.tsx— hook unit tests (window sizing, growth, exhaustion no-op, reset on dependency change).
-
Seed volume. Create a wallet holding a large invoice set. A fast way to simulate mainnet scale without a real ledger is to stub the data source (MSW handler for the invoices query) to return N invoices, e.g.
N = 2_000,5_000, and10_000. -
Measure TTI + scroll. Open the page in a clean profile with throttle (e.g. 6x CPU / 4x network) and record:
- Time to Interactive (TTI) — performance entry observed via
performance.getEntriesByType('longtask')andLargestContentfulPaint. - First interaction latency — time from first click to the table being responsive.
- Scroll frame rate — use the DevTools Performance recorder and count dropped frames while scrolling to the bottom of the table.
- Time to Interactive (TTI) — performance entry observed via
-
Verify the bounded window. With the DevTools console listening for the analytics event:
window.addEventListener('iln:analytics', (e) => console.log(e.detail));
The
list:visible-windowevents should showvisibleCountcapped at the page size (50) and growing only when "Load more" is clicked, whiletotalreflects the full seeded size. -
Before/after. Repeat steps 1-3 on the pre-change commit (
main) and on this branch. Expect:- ~Constant initial render cost regardless of
N(window capped at 50). - Number of DOM
<tr>elements ≈visibleCount(notN). - No long tasks / dropped frames attributable to rendering the full list.
- ~Constant initial render cost regardless of
With seedN = 10_000 on /dashboard table mode:
- Before: 10,000
<tr>mounted; TTI and first scroll interaction degrade sharply; long tasks > 200 ms common. - After: 50
<tr>mounted initially; "Load more" reveals 50 at a time; TTI and scrolling remain flat acrossN.
Issue: Invoice-Liquidity-Network/ILN-Frontend#943
An active LP with a long invoice/governance history can build up many notifications. This section records how the notification surfaces behave at that volume.
| Surface | Bounding before | Status |
|---|---|---|
NotificationContext writes |
addNotification / setNotifications capped at MAX_NOTIFICATIONS (50) |
OK |
NotificationContext load from storage |
Uncapped: a stored list from an older build or another writer was loaded in full | Fixed |
/notifications page (NotificationsPage) |
Every item in context mounted, re-sorted on every render | Fixed |
Notification drawer (NotificationDrawer) |
Every item in context mounted, re-sorted on every render | Fixed |
NotificationBell dropdown |
Sorted and sliced to MAX_NOTIFICATIONS |
OK |
The same bounded "Load more" window as the invoice tables above, again with no virtualization dependency:
- The page and drawer render through
useVisibleWindowwithNOTIFICATIONS_PAGE_SIZE(20) rows, plus a "Load more (N remaining)" button. They emitlist:visible-windowanalytics asnotifications-pageandnotification-drawer. - The newest-first sort moved into
sortNotificationsNewestFirst(src/utils/notificationHelpers.ts) and is memoized, so it no longer runs on every render. NotificationContextnow caps the stored list atMAX_NOTIFICATIONSwhen it loads, not only when it writes.
src/screens/__tests__/NotificationsPage.test.tsxandsrc/components/__tests__/NotificationDrawer.highVolume.test.tsxrender a simulated 5,000-notification account. They assert that onlyNOTIFICATIONS_PAGE_SIZErows are mounted, newest first, that "Load more" reveals the next page, and that the initial render stays inside a generous time budget.src/context/__tests__/NotificationContext.test.tsxseeds 5,000 stored notifications and a 5,000-item bulk replace, and asserts both are capped atMAX_NOTIFICATIONS.
- History is still kept client-side and capped at
MAX_NOTIFICATIONS(50), so older events drop off. Full history needs server-side pagination onGET /api/notifications/[address]; that is out of scope here. - The timing checks run in jsdom. They catch a regression back to unbounded rendering, but they do not measure real browser paint or scroll cost.
Issue: Invoice-Liquidity-Network/ILN-Frontend#939
The status page itself is hosted on Instatus (see status-page-runbook.md) and is not part of this repository, so it cannot be load-tested here. What this repository does own is the in-app side of the incident path: the surfaces that tell a user a status-page component is degraded while they are using the app. This section records how those surfaces behave when several components fail at once, not just one incident at a time.
__tests__/status-multi-incident-load.test.tsx renders the incident surfaces
together and fails every dependency simultaneously for a simulated ten minutes
(fake timers):
| Status-page component | Simulated failure | In-app surface |
|---|---|---|
| API / Indexer | Indexer WebSocket and Horizon event stream both refuse | ContractEventSync alert (useContractEvents) |
| Smart Contracts | Contract reports paused (get_protocol_status()) |
MaintenanceModeBanner |
| Notifications service | GET /api/notifications/[address] returns 503 (circuit open) |
NotificationBell degraded marker |
It uses the real connectIndexerWebSocket / connectHorizonTransactionStream
clients against failing WebSocket / EventSource stubs, so the stream
clients' own reconnect loops take part in the simulation.
Assertions:
- Each degraded component renders exactly one indicator (one maintenance banner, one contract-event alert, one degraded marker).
- Contract-event reconnects are bounded: one WebSocket attempt, then one Horizon stream per hook-level attempt (initial + 3 retries = 4) before the alert asks the user to refresh.
- The notifications service is polled once per 60 s interval and never retried
on
503. - Nothing keeps connecting or polling after the surfaces unmount.
- Reconnect storm in the Horizon fallback (fixed). When a Horizon stream
reported
disconnected,useContractEventsscheduled a new stream but left the failed one open. Its own reconnect loop (up to 8 attempts) kept running and every furtherdisconnectedit reported scheduled another stream. The retried streams were also never stored, so unmounting could not close them. Tracing the code, one tab could open up to 820 Horizon streams (about 7,400EventSourceconnections) during a combined indexer and Horizon outage. The hook now closes a failed stream before retrying, ignores anything it reports afterwards, and keeps the retried stream inhorizonHandleRefso cleanup closes it. - Duplicate Horizon fallback (fixed). A WebSocket that drops without an
error reports
disconnected, thenerrorfrom its pending reconnect. Both triggered the fallback, opening two Horizon streams and leaking the first. The fallback now runs once per WebSocket connection. - Extra notification requests (fixed).
NotificationBellrestarted its poll, sending an immediate extra request, whenever read state changed (on mount and on every "mark as read"). It now reads the latest callbacks through refs and polls only on its fixed interval.
- The Instatus-hosted page, its subscriber notifications, and the automation planned in #934 to #938 are not in this repository or not built yet, so none of them are covered here. See the status page and incident tooling readiness report.
getProtocolStatus()falls back to{ paused: false }when the Stellar RPC is unreachable, so an RPC outage shows no maintenance banner. This is deliberate (the banner never blocks rendering); the component-level check in #934 / #871 is where RPC reachability belongs.- The simulation runs in jsdom with fake timers. It checks request and connection counts, not real network throughput.