From 6a970c665e54f247a0cd1d0ac96dc019ebbe00b5 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:49:36 +0100 Subject: [PATCH 1/2] fix(portfolio): show an error state instead of $0.00 when the portfolio fetch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `fetchAndUpdatePortfolio` threw, the catch block recorded a diagnostic but still called `markLoaded()`, leaving the portfolio refs `undefined`. The page's display logic only substitutes "—" when positions exist with missing prices, so an outright load failure fell through to formatted zeros: "$0.00 Supplied / $0.00 Borrowed / $0.00 Net Worth". For an account with real deposits and borrows this is indistinguishable from an empty account, and the recorded diagnostics were consumed nowhere in the UI. Track an explicit `hasPositionsFetchError` flag, set on a thrown fetch and cleared on every fresh attempt, and expose `hasPortfolioLoadError` — true only when a fetch failed AND there is no (stale) portfolio to fall back on, so a transient background-refresh failure keeps the last-good figures on screen while a genuine cold-load failure surfaces an error. The portfolio page renders a dismissable-free error banner with a Retry action and shows "—"/"-" for the totals and performance figures instead of zeros. Mirrors the existing "Position data is incomplete" handling on the per-position page. Adds a unit test covering the failed-fetch path. --- composables/useEulerAccount.ts | 14 +++++++ pages/portfolio.vue | 46 +++++++++++++++++---- tests/composables/useEulerAccount.test.ts | 50 ++++++++++++++++------- 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/composables/useEulerAccount.ts b/composables/useEulerAccount.ts index ca024f8d1..2f5dd033a 100644 --- a/composables/useEulerAccount.ts +++ b/composables/useEulerAccount.ts @@ -28,6 +28,10 @@ const isPositionsLoaded = ref(false) const isDepositsLoading = ref(true) const isDepositsLoaded = ref(false) const isShowAllPositions = ref(false) +// True when the most recent portfolio fetch threw, as opposed to succeeding +// with partial per-asset pricing issues. Surfaced to the UI so a failed load +// renders an error state instead of formatted zeros — see `hasPortfolioLoadError`. +const hasPositionsFetchError = ref(false) // Transparent layer overlay: when a non-zero batch layer is active, the // simulated portfolio is served for both the visible and all-positions views, @@ -101,6 +105,7 @@ export const useEulerAccount = () => { visiblePortfolio.value = undefined allPortfolio.value = undefined portfolioDiagnostics.value = [] + hasPositionsFetchError.value = false isPositionsLoaded.value = false isPositionsLoading.value = true isDepositsLoaded.value = false @@ -121,6 +126,7 @@ export const useEulerAccount = () => { const gen = positionGuard.current() try { + hasPositionsFetchError.value = false if (!walletAddress) { visiblePortfolio.value = undefined allPortfolio.value = undefined @@ -162,6 +168,7 @@ export const useEulerAccount = () => { catch (error) { if (positionGuard.isStale(gen)) return logWarn('useEulerAccount/fetchAndUpdatePortfolio', error) + hasPositionsFetchError.value = true portfolioDiagnostics.value = [{ code: 'SOURCE_UNAVAILABLE', severity: 'error', @@ -227,6 +234,12 @@ export const useEulerAccount = () => { startWatchers() onScopeDispose(releaseWatchers) + // A portfolio fetch failed AND we have no (stale) portfolio to fall back on. + // Gating on the absence of data means a transient background-refresh failure + // keeps the last-good figures on screen, while a genuine cold-load failure + // lets the UI show an error instead of misleading $0.00 totals. + const hasPortfolioLoadError = computed(() => hasPositionsFetchError.value && !portfolio.value) + const portfolioRoe = computed(() => portfolio.value?.roe ?? 0) const portfolioNetApy = computed(() => portfolio.value?.netApy ?? 0) const totalSuppliedValue = computed(() => usdWadToNumber(portfolio.value?.totalSuppliedValueUsd)) @@ -276,6 +289,7 @@ export const useEulerAccount = () => { return { portfolio, portfolioDiagnostics, + hasPortfolioLoadError, borrowPositions, depositPositions, removedBorrowPositions, diff --git a/pages/portfolio.vue b/pages/portfolio.vue index ad0eee945..1c76e84d3 100644 --- a/pages/portfolio.vue +++ b/pages/portfolio.vue @@ -20,6 +20,7 @@ const { isPositionsLoaded, isShowAllPositions, refreshAllPositions, + hasPortfolioLoadError, } = useEulerAccount() const { refresh: refreshFreshAccount } = useFreshAccount() const { rewards } = useSdkRewards() @@ -134,17 +135,23 @@ watch([() => route.name, showMigrationTab, hasMigrationLoaded, isMigrationLoadin }) const portfolioNetApyDisplay = computed(() => - Number.isFinite(portfolioNetApy.value) ? `${formatNumber(portfolioNetApy.value)}%` : '-', + !hasPortfolioLoadError.value && Number.isFinite(portfolioNetApy.value) + ? `${formatNumber(portfolioNetApy.value)}%` + : '-', ) const portfolioRoeDisplay = computed(() => - Number.isFinite(portfolioRoe.value) ? `${formatNumber(portfolioRoe.value)}%` : '-', + !hasPortfolioLoadError.value && Number.isFinite(portfolioRoe.value) + ? `${formatNumber(portfolioRoe.value)}%` + : '-', ) const totalSuppliedDisplay = computed(() => { + if (hasPortfolioLoadError.value) return '—' const { total, hasMissingPrices } = totalSuppliedValueInfo.value if (total === 0 && hasMissingPrices) return '—' return formatCompactUsdValue(total) }) const totalBorrowedDisplay = computed(() => { + if (hasPortfolioLoadError.value) return '—' const { total, hasMissingPrices } = totalBorrowedValueInfo.value if (total === 0 && hasMissingPrices) return '—' return formatCompactUsdValue(total) @@ -153,6 +160,7 @@ const netAssetValueInfo = computed(() => { return netAssetMarketValueInfo.value }) const netAssetValueDisplay = computed(() => { + if (hasPortfolioLoadError.value) return '—' const { total, hasMissingPrices } = netAssetValueInfo.value if (total === 0 && hasMissingPrices) return '—' return formatCompactUsdValue(total) @@ -173,6 +181,10 @@ const updatePositions = async ( }) } +const retryPortfolioLoad = () => { + void updatePositions({ portfolioSource: 'fresh', preemptPortfolio: true }) +} + onActivated(async () => { await updateBalances() updatePositions() @@ -218,6 +230,26 @@ watch(showAllLabelEntries, (showAll) => { +
+ + + We couldn't load your portfolio. Your funds are safe on-chain — this is usually temporary. + + +
+
@@ -240,7 +272,7 @@ watch(showAllLabelEntries, (showAll) => { data-id="data-point" :data-key="spyAddress || address" data-field="portfolio-net-apy" - :data-value="Number.isFinite(portfolioNetApy) ? portfolioNetApy : '-'" + :data-value="hasPortfolioLoadError ? '-' : (Number.isFinite(portfolioNetApy) ? portfolioNetApy : '-')" > {{ portfolioNetApyDisplay }}
@@ -263,7 +295,7 @@ watch(showAllLabelEntries, (showAll) => { data-id="data-point" :data-key="spyAddress || address" data-field="portfolio-roe" - :data-value="Number.isFinite(portfolioRoe) ? portfolioRoe : '-'" + :data-value="hasPortfolioLoadError ? '-' : (Number.isFinite(portfolioRoe) ? portfolioRoe : '-')" > {{ portfolioRoeDisplay }}
@@ -291,7 +323,7 @@ watch(showAllLabelEntries, (showAll) => { data-id="data-point" :data-key="spyAddress || address" data-field="portfolio-total-supplied" - :data-value="totalSuppliedValueInfo.hasMissingPrices ? totalSuppliedDisplay : totalSuppliedValueInfo.total" + :data-value="hasPortfolioLoadError ? '—' : (totalSuppliedValueInfo.hasMissingPrices ? totalSuppliedDisplay : totalSuppliedValueInfo.total)" > {{ totalSuppliedDisplay }}
@@ -314,7 +346,7 @@ watch(showAllLabelEntries, (showAll) => { data-id="data-point" :data-key="spyAddress || address" data-field="portfolio-total-borrowed" - :data-value="totalBorrowedValueInfo.hasMissingPrices ? totalBorrowedDisplay : totalBorrowedValueInfo.total" + :data-value="hasPortfolioLoadError ? '—' : (totalBorrowedValueInfo.hasMissingPrices ? totalBorrowedDisplay : totalBorrowedValueInfo.total)" > {{ totalBorrowedDisplay }} @@ -337,7 +369,7 @@ watch(showAllLabelEntries, (showAll) => { data-id="data-point" :data-key="spyAddress || address" data-field="portfolio-net-asset-value" - :data-value="netAssetValueInfo.hasMissingPrices ? netAssetValueDisplay : netAssetValueInfo.total" + :data-value="hasPortfolioLoadError ? '—' : (netAssetValueInfo.hasMissingPrices ? netAssetValueDisplay : netAssetValueInfo.total)" > {{ netAssetValueDisplay }} diff --git a/tests/composables/useEulerAccount.test.ts b/tests/composables/useEulerAccount.test.ts index 3efdb00d9..08566d0f7 100644 --- a/tests/composables/useEulerAccount.test.ts +++ b/tests/composables/useEulerAccount.test.ts @@ -3,22 +3,25 @@ import { effectScope, nextTick, ref, type EffectScope } from 'vue' const owner = '0x1000000000000000000000000000000000000000' -const importUseEulerAccount = async () => { +const importUseEulerAccount = async ({ failFetch = false }: { failFetch?: boolean } = {}) => { vi.resetModules() - const fetchPortfolio = vi.fn(async () => ({ - errors: [], - result: { - account: { owner }, - borrows: ['all-borrow'], - savings: ['all-saving'], - totalSuppliedValueUsd: 100, - totalBorrowedValueUsd: 25, - netAssetValueUsd: 75, - roe: 3, - netApy: 2, - }, - })) + const fetchPortfolio = vi.fn(async () => { + if (failFetch) throw new Error('portfolio source unavailable') + return { + errors: [], + result: { + account: { owner }, + borrows: ['all-borrow'], + savings: ['all-saving'], + totalSuppliedValueUsd: 100, + totalBorrowedValueUsd: 25, + netAssetValueUsd: 75, + roe: 3, + netApy: 2, + }, + } + }) const buildPortfolio = vi.fn(() => ({ account: { owner }, borrows: ['visible-borrow'], @@ -122,4 +125,23 @@ describe('useEulerAccount', () => { expect(account?.depositPositions.value).toEqual(['all-saving']) expect(account?.totalSuppliedValue.value).toBe(100) }) + + it('flags a load error (rather than showing zeros) when the fetch fails with no data to fall back on', async () => { + const { useEulerAccount, fetchPortfolio } = await importUseEulerAccount({ failFetch: true }) + + let account: ReturnType | undefined + scope = effectScope() + scope.run(() => { + account = useEulerAccount() + }) + + await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalled()) + await vi.waitFor(() => expect(account?.hasPortfolioLoadError.value).toBe(true)) + + // No portfolio is present, so the totals are zero — the error flag is what + // lets the page render an error state instead of a misleading $0.00. + expect(account?.portfolio.value).toBeUndefined() + expect(account?.totalSuppliedValue.value).toBe(0) + expect(account?.portfolioDiagnostics.value.some(issue => issue.severity === 'error')).toBe(true) + }) }) From 95a23fc2f70ab476c08c776d44a0f371284b9c35 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:09:02 +0100 Subject: [PATCH 2/2] fix: keep portfolio error during retry Clear the portfolio fetch error only after a successful refresh confirms replacement data, and cover the pending retry window with a composable regression test. --- composables/useEulerAccount.ts | 3 +- tests/composables/useEulerAccount.test.ts | 92 +++++++++++++++++------ 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/composables/useEulerAccount.ts b/composables/useEulerAccount.ts index 2f5dd033a..6edb1d48e 100644 --- a/composables/useEulerAccount.ts +++ b/composables/useEulerAccount.ts @@ -126,11 +126,11 @@ export const useEulerAccount = () => { const gen = positionGuard.current() try { - hasPositionsFetchError.value = false if (!walletAddress) { visiblePortfolio.value = undefined allPortfolio.value = undefined portfolioDiagnostics.value = [] + hasPositionsFetchError.value = false markLoaded() return } @@ -163,6 +163,7 @@ export const useEulerAccount = () => { allPortfolio.value = nextAllPortfolio visiblePortfolio.value = nextVisiblePortfolio portfolioDiagnostics.value = fetched.errors + hasPositionsFetchError.value = false markLoaded() } catch (error) { diff --git a/tests/composables/useEulerAccount.test.ts b/tests/composables/useEulerAccount.test.ts index 08566d0f7..7410baecd 100644 --- a/tests/composables/useEulerAccount.test.ts +++ b/tests/composables/useEulerAccount.test.ts @@ -3,35 +3,47 @@ import { effectScope, nextTick, ref, type EffectScope } from 'vue' const owner = '0x1000000000000000000000000000000000000000' -const importUseEulerAccount = async ({ failFetch = false }: { failFetch?: boolean } = {}) => { +const fetchedPortfolio = { + account: { owner }, + borrows: ['all-borrow'], + savings: ['all-saving'], + totalSuppliedValueUsd: 100, + totalBorrowedValueUsd: 25, + netAssetValueUsd: 75, + roe: 3, + netApy: 2, +} + +const visiblePortfolio = { + account: { owner }, + borrows: ['visible-borrow'], + savings: [], + totalSuppliedValueUsd: 40, + totalBorrowedValueUsd: 10, + netAssetValueUsd: 30, + roe: 1, + netApy: 0.5, +} + +const portfolioResponse = () => ({ + errors: [], + result: fetchedPortfolio, +}) + +const importUseEulerAccount = async ( + { failFetch = false, fetchPortfolioImpl }: { + failFetch?: boolean + fetchPortfolioImpl?: () => Promise> + } = {}, +) => { vi.resetModules() const fetchPortfolio = vi.fn(async () => { + if (fetchPortfolioImpl) return fetchPortfolioImpl() if (failFetch) throw new Error('portfolio source unavailable') - return { - errors: [], - result: { - account: { owner }, - borrows: ['all-borrow'], - savings: ['all-saving'], - totalSuppliedValueUsd: 100, - totalBorrowedValueUsd: 25, - netAssetValueUsd: 75, - roe: 3, - netApy: 2, - }, - } + return portfolioResponse() }) - const buildPortfolio = vi.fn(() => ({ - account: { owner }, - borrows: ['visible-borrow'], - savings: [], - totalSuppliedValueUsd: 40, - totalBorrowedValueUsd: 10, - netAssetValueUsd: 30, - roe: 1, - netApy: 0.5, - })) + const buildPortfolio = vi.fn(() => visiblePortfolio) const sdk = { portfolioService: { fetchPortfolio, @@ -144,4 +156,36 @@ describe('useEulerAccount', () => { expect(account?.totalSuppliedValue.value).toBe(0) expect(account?.portfolioDiagnostics.value.some(issue => issue.severity === 'error')).toBe(true) }) + + it('keeps the load error visible until a retry confirms new data', async () => { + let resolveRetry: ((value: ReturnType) => void) | undefined + const fetchPortfolioImpl = vi.fn() + .mockRejectedValueOnce(new Error('portfolio source unavailable')) + .mockImplementationOnce(() => new Promise>((resolve) => { + resolveRetry = resolve + })) + + const { useEulerAccount, fetchPortfolio } = await importUseEulerAccount({ fetchPortfolioImpl }) + + let account: ReturnType | undefined + scope = effectScope() + scope.run(() => { + account = useEulerAccount() + }) + + await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(account?.hasPortfolioLoadError.value).toBe(true)) + + const retry = account!.refreshAllPositions(undefined, owner, { preempt: true }) + await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalledTimes(2)) + + expect(account?.portfolio.value).toBeUndefined() + expect(account?.hasPortfolioLoadError.value).toBe(true) + + resolveRetry?.(portfolioResponse()) + await retry + + expect(account?.portfolio.value).toEqual(visiblePortfolio) + expect(account?.hasPortfolioLoadError.value).toBe(false) + }) })