Skip to content

feat(balances): align the $0.00 Testnet balance UI with mobile + tweaks - #2960

Merged
CassioMG merged 43 commits into
masterfrom
feature/balance-loading-states
Aug 17, 2026
Merged

feat(balances): align the $0.00 Testnet balance UI with mobile + tweaks#2960
CassioMG merged 43 commits into
masterfrom
feature/balance-loading-states

Conversation

@CassioMG

@CassioMG CassioMG commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Aligns the $0.00 Testnet balance UI with the mobile app (freighter-mobile#943), plus a few other small polishes found along the way listed here and it also prunes and review stale translations from the codebase.

Balance display

*UPDATED, see PR comment at the bottom. We will need to port some changes back to mobile as well to keep both in parity.

  • New Testnet behavior: Wallets rows show a spinner while an account's total is still pending instead of the literal ..., and settle on $0.00 rather than an empty cell since we have no prices (or "real-world" value) on Testnet. The Home hero also defaults to $0.00 for the same reason.

  • New Mainnet behavior: Wallets rows show a spinner while an account's total is still pending instead of the literal ..., and settle on its expected total USD balance or -- if there was an error while fetching prices. The Home hero is also never hidden — it defaults to $0.00 for unfunded accounts and defaults to -- for funded accounts if there was an error while fetching prices.

Home token rows are deliberately unchanged: unpriced tokens keep showing the -- dashes.

Regular experience with quick load + "$0.00" labels on Testnet

balances-load-regular-experience.mov

Experience with long load times showing the row spinners

long-loading-times.mov

"Imported" label (long standing bug on master)

Recovery pre-loads derivation indices 1..n and was storing them as imported, so every account but the first came back labelled "Imported". They come from the recovery phrase, exactly like the ones addAccount creates. The label now means what it says: brought in from a secret key.

Fixes new recoveries only — the flag is rehydrated from the keystore, so wallets recovered before this keep the old label until recovered again.

Before fix (video from master branch)

import-label-bug.mov

After fix (video from this PR's branch)

import-label-fix.mov

Unfunded empty state

Test networks stacked two competing funding buttons. Friendbot now replaces "Add XLM" rather than sitting under it, in the same style, one size larger.

Before fix

Screenshot 2026-08-12 at 17 39 12

After fix

Screenshot 2026-08-12 at 17 36 37

Home error state

The hero shows $0.00 label when the fetch fails, for consistency with the wallets rows and with mobile.
The error notification now gets a horizontal gutter.
The account identicon also went blank here: the header was handed an empty public key once the fetch returned no data, while the name kept coming from Redux. It now falls back to Redux too, which also repairs the header's copy button and account-details link in this state.

Before fix

Screenshot 2026-08-12 at 14 14 01

After fix

Screenshot 2026-08-12 at 17 34 01

Wallets error state (long standing bug on master)

The error branch returned the notification on its own, leaving no close button, no account actions and no Add wallet — an error you could not navigate out of. It is now scoped to the list region.

Before fix

Screenshot 2026-08-12 at 14 19 55

After fix

Screenshot 2026-08-12 at 17 34 07

Fullscreen layout (long standing bug on master)

The layout only sized to its content, so the vertical borders stopped partway down the window. Also gives the Home notifications their horizontal gutter, and repairs an invalid margin-top shorthand that left the fullscreen notice glued to the edges.

Before fix

Screenshot 2026-08-12 at 17 39 54 Screenshot 2026-08-12 at 17 42 05

After fix

Screenshot 2026-08-12 at 18 04 15 Screenshot 2026-08-12 at 17 34 38

yarn test:ci 201 suites / 1543 tests, yarn build:extension clean. New coverage for WalletRow, NotFundedMessage, formatFiatAmount and the recovery flag.

🤖 Generated with Claude Code

CassioMG and others added 22 commits August 4, 2026 01:12
Restyle the Wallets screen per node 9573-23172: an active-account header
block (identicon, name, address, and four action buttons for QR, copy,
stellar.expert, and rename) replaces the per-row ellipsis dropdown. Each
WalletRow becomes a three-column row (identicon / name+address / balance)
with "Imported" shown inline beside the address.

- Copy and stellar.expert buttons add call sites for the existing
  account.public_key_copied / account.stellar_expert_opened metrics, with
  no arguments (a `source` property is deferred to a follow-up PR).
- The explorer button is hidden via isCustomNetwork(networkDetails),
  mirroring ViewPublicKey.
- Tapping a row still makes that account active and navigates to Home
  (spec D4) - rename/copy/QR/explorer now act on the active account only.
- Removes dead `.detail-indentifier`, `.WalletRow__options-actions`, and
  `.Wallets__fail` CSS/attribute orphans.
- Hand-added new i18n keys to en/pt since build:extension:translations is
  broken on master (pre-existing, out of scope here).
The per-row ellipsis dropdown (and its wallet-row-options testid) is gone;
rename now acts on the active account through the header pencil button.
Repoints "Renames wallets" at wallets-header-edit-name, and scopes its
final assertions to the header/row locators specifically since the new
name now legitimately renders in two places (and this seed phrase has
several accounts sharing the row list).

Also updates "Loads wallets data and token prices on Mainnet in batches":
address and balance are no longer a single concatenated string, so each
per-account assertion now checks a row's balance cell scoped by its
address rather than matching literal "address - $balance" text.
… own styles

SDS Button assigns its own className internally and then spreads the rest
of its props over the element, so any className passed in by a caller
replaces (rather than merges with) Button/Button--tertiary/etc. instead of
augmenting them. That silently dropped every rule meant for the restyled
add-wallet button, leaving it transparent, gray, and sized off its
unconstrained icon/text content.

Wrap the Button in a plain div instead and scope the token overrides
through that wrapper, targeting `.Button` and `.Button__icon` by
descendant selector so they win on specificity without touching the
className prop at all. Confirmed via computed-style checks that the
button now resolves to the intended lilac-02/09/11 tokens at a 34px
pill height, matching the design spec.
Corrects eight deviations from Figma frame 9573-23172:

- Header name and address are now one tight block; the 16px gap applies
  only between the identicon, that block, and the action row.
- QR icon qr-code-01 -> qr-code-02, pencil icon edit-05 -> edit-01.
- The divider sits inside the 24px content gutter instead of bleeding to
  the window edges, and is held 16px off the list below it.
- Row avatars lose their border and white fill for a flat gray circle,
  with the identicon inset to 16px inside the 40px avatar.
- The active row's selected state drops its colored border in favor of a
  16px navy badge flush to the avatar's bottom-right corner.
- "Imported" is separated from the address by a bullet.
- The add-wallet control keeps its tint on the icon chip alone rather
  than behind the whole button, at medium weight.

The button's copy becomes "Add wallet", which already exists as a key
(it is the AddWallet sheet's own title), so this adds no new strings and
retires the now-orphaned "Add a wallet". That collision is also why the
e2e click moves to the add-wallet testid: the label alone would be
ambiguous once the sheet it opens is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The copy button now shows the same success/error toast the QR screen
  shows, replacing CopyText's inline "Copied!" balloon. The two surfaces
  do the same thing, so they should confirm it the same way. The metric
  moves into the handler and fires only after the clipboard write
  resolves, so failed copies still aren't counted.
- Restores SDS's `xl` padding on the add-wallet button. Its 16px/24px
  text already matches the spec, so only the colors, gap and weight stay
  overridden; the tint remains on the icon chip alone.
- Removes the wallet row's hover background.

"Copied!" stays in the catalog — AccountHeader still uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--font-weight-medium` is defined nowhere — not in this repo and not in
SDS, which names it `--sds-fw-medium`. An undefined custom property makes
the whole declaration invalid, and because font-weight inherits, every
element referencing it silently rendered at the body's 400. Measured in
a browser before the fix, the add-wallet label, the header name, the row
names and the balances were all 400; the spec calls for 500 on each.

Swapping the six occurrences in these two files for `--sds-fw-medium`
and `--sds-fw-regular` puts them at 500/400 as the spec's Text/MD/500,
Text/SM/500 and Text/SM/400 styles require. This also means the earlier
"less bold" pass on the add-wallet label was not setting medium at all,
only cancelling SDS's semi-bold down to regular by accident.

The same undefined token is referenced from BackButton,
TransactionHeading, Notification and AmountCard among others, all with
the same silent fallback. Those are left alone here.

Also drops the add-wallet button's left padding so its icon chip lines
up with the list's avatars in the 24px gutter, and gives the content
footer an even 8px above and below, scoped with :has() so no other
view's footer changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses Copilot review on #2933.

The explorer action was gated on `!isCustomNetwork`, which only excludes
STANDALONE. Experimental mode switches the active network to Futurenet
(saveExperimentalFeatures), and stellar.expert has no Futurenet explorer,
so the action rendered and built an unsupported /explorer/futurenet/ URL.
This is long-standing logic from #616 rather than a regression here, but
#2930 removed the QR screen's copy, leaving this the only caller — so it
gets fixed here.

Note that switching to the shared `getStellarExpertUrl` helper alone does
not fix it: the helper falls through to /public for anything that isn't
testnet, so an ungated Futurenet link would render a *mainnet* lookup for
a Futurenet account — wrong data, silently. The gate is what matters, so
this adds `isStellarExpertSupported` beside the helper to keep the two
together, with unit tests pinning the Futurenet and custom-network
exclusions and documenting that fallback.

WalletRow gains `role="button"` and `aria-current`, matching BalanceRow,
the sibling list row. The active account was previously conveyed only by
the badge on its avatar, which says nothing to a screen reader. Left
alone: full keyboard focusability. No row component in this codebase
(BalanceRow, ProtocolRow) sets tabIndex or key handlers, so making this
one a semantic button would be inconsistent without addressing that
codebase-wide.

Also adds e2e coverage for the copy action, asserting the clipboard
contents and the toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the balance loading/display behavior from freighter-mobile#943.

Wallets list rows
  A row shows a spinner while that account's own total is still pending,
  instead of the literal "...". Totals arrive in batches of 6, so the
  guard is per-account (`!accountValue && isFetchingTokenPrices`) rather
  than the global flag alone — rows 1-6 render real values while 7+ still
  spin, which is the one frame where this is observable. Once loading
  settles a total always renders, defaulting to $0.00 rather than an
  empty cell.

  The check is falsy rather than `== null` on purpose: the data hook
  writes "" for an account whose fetch threw, which is equally "no
  total". `accountValue` is also now optional, which it always was at
  runtime — Wallets/index.tsx hands `undefined` to a prop typed `string`.

Home hero
  Shows the total whenever account data resolved, defaulting to $0.00
  instead of collapsing when nothing is priced. That is every non-Mainnet
  network (no price feed) and any failed price fetch. No spinner: the
  extension awaits prices before its first success dispatch, so that
  window is already covered by the full-screen loader and a hero spinner
  would be unreachable.

  `hasError` still yields an empty string. There the balances themselves
  are unknown, a failure notification is already on screen, and
  `resolvedData` is null — so $0.00 would be both misleading and unsafe
  to compute (the `!hasError` short-circuit guards a `resolvedData!`).

Token rows are untouched: unpriced tokens keep showing dashes. The
assertions at Account.test.tsx:1065-1087 are the regression guard.

Also adds `formatFiatAmount`, so the four sites that were assembling
`$${formatAmount(roundUsdValue(x))}` share one zero. It coerces
non-finite input rather than taking a default parameter, which would
have covered `undefined` but let "" through as "$NaN".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The error branch returned the notification on its own, short-circuiting
before SubviewHeader and View.Content. That left the screen with no close
button, no account actions and no Add wallet — an error state the user
could not navigate out of at all, short of closing the popup.

The failure is now scoped to the list region, so the header, the active
account block and the footer stay put. `dataState.data` is null in the
error state, so the active key falls back to Redux, which still holds it
from the last successful load — the chrome never needed the fetch.

Also gives the Home error notifications their horizontal gutter. Home
disables the content inset (`View.Content hasNoPadding`), so notifications
have to bring their own margin or they run edge to edge;
`.AccountView__fetch-fail` only set `margin-top`. Sides only, since
`margin-top` is what animateNotification animates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recovering a wallet probes derivation indices 1..n and pre-loads any that
are funded on Mainnet. Those were stored with `imported: true`, so every
account but the first came back labelled "Imported" in the wallets list —
which is what the user sees, and it is wrong: they are derived from the
recovery phrase just taken, exactly like the ones `addAccount` produces
from the same phrase, and that path stores them unflagged.

"Imported" means an account brought in from a secret key, which the
phrase cannot re-derive. Only importAccount should set the flag (plus
hardware wallets, which get it from their key-id prefix in
getStoredAccounts and are a separate notion).

Matches mobile, which drives its label off `importedFromSecretKey`.

Note this fixes new recoveries only. `getStoredAccounts` rehydrates the
flag from the keystore's `extra.imported` on every unlock, so wallets
recovered before this change keep the wrong label until they are
recovered again. Correcting those needs a migration that rewrites the
keystore, which is only possible while unlocked — deliberately not
attempted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the last case where the total was hidden. The hero now renders a
value whenever the screen does: non-Mainnet networks, a failed price
fetch, and now a failed balances fetch alike.

This aligns three things that had drifted apart. Mobile's hero has no
error branch at all — it renders `formattedBalance`, which is a summed
zero when nothing resolved, and surfaces the failure separately in the
tokens list. The wallets rows already settle on $0.00 for an account
whose fetch failed. Only the extension's hero still collapsed, which
made the same underlying failure look different in two places.

Safe without the previous `hasError` guard: `balances` already falls
back to [] and `tokenPrices` to undefined, so getTotalUsd sums nothing
and returns zero without touching the null `resolvedData`. The earlier
guard was load-bearing only because the expression used to dereference
`resolvedData!.networkDetails`, which this branch removed.

The failure is still reported — the "Failed to fetch your account
balances." notification is unchanged, and the token rows still show
dashes rather than zeros.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things that only showed up in the error state.

`.AccountView__fullscreen` carried `margin-top: 24px 24px 0`. `margin-top`
takes a single value, so the declaration was invalid and dropped whole —
the fullscreen notice rendered flush against the window edges with no
spacing at all. It now uses the same gutter as `__fetch-fail`, with a
bottom margin too so it isn't glued to the content beneath it.

The Home header passed `resolvedData?.publicKey || ""` to AccountHeader,
and `resolvedData` is null in the error state, so the identicon was
handed an empty key and rendered blank. The account *name* kept working
because it reads from Redux — hence a named account with no avatar. The
key now falls back to Redux as well, which also repairs the header's
copy button and account-details link in that state. Same fix the wallets
list already had; this is why the identicon showed there but not here.

No unit assertion for the identicon: jsdom has no canvas, so
createStellarIdenticon().toDataURL() returns one constant for every
input, including "". A test would have passed with the bug still in
place — confirmed by trying it. Verified in a real browser instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Home layout's vertical borders stopped partway down the window in
fullscreen, leaving the sides unframed below the content.

`View--scrollable` set `height: 100%`, which overrides the base
`height: 100dvh`. The parent chain (a bare wrapper div, body, html) has
no height of its own, so that percentage resolved to `auto` and the
layout was only ever as tall as its content. In the popup this is
invisible, because the base `min-height` is the popup height — the
mismatch only shows once the window is taller than the content.

Home is the only view with this class (Router passes isScrollableView
for "/"), and it has to be able to grow past the viewport rather than
clip like the fixed-height views. So: grow with the content, but never
be shorter than it. `View__content` is already `flex: 1`, so it absorbs
the slack and the inset borders run the full height.

Measured at 900px and 1400px viewports: the layout and the lowest
bordered inset now both end exactly at the window bottom, where the
layout previously stopped at 602px. Popup dimensions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Add XLM" rendered unconditionally and Friendbot was appended below it,
so every test network showed two stacked funding buttons competing for
the same job.

Where a friendbot exists it is how you fund the account, so it now
replaces "Add XLM" rather than sitting under it, and takes the same
`secondary` style — it is the primary action of this empty state, not a
lesser alternative offered alongside one.

Mainnet is unaffected: no friendbot there, so "Add XLM" still renders
and still routes to Add Funds. Both e2e consumers stay valid — the login
helper drives Friendbot on Testnet, and buyWithOnramp switches to
Mainnet before clicking "Add XLM".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
md -> lg on both branches of the funding action, so the empty state's
only call to action carries more weight. 32px tall becomes 40px with
roomier padding; SDS keeps the 14px text across both sizes, so this
grows the target without changing the type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-1359a2fae35cf9e4baad (SDF collaborators only — install instructions in the release description)

@CassioMG
CassioMG marked this pull request as ready for review August 13, 2026 01:05
@CassioMG CassioMG self-assigned this Aug 13, 2026
24b88d4 replaced the base `min-height: var(--popup--height)` with
`min-height: 100dvh` to make the layout fill the window in fullscreen.
That regressed the popup: with only a few tokens it opened short.

`100dvh` is circular there. Chrome sizes the popup window to the
document, so the floor depended on the viewport, which depended on the
content, which depended on the floor; a short account settled well under
600px. The fixed `--popup--height` is what broke that cycle, and
dropping it removed the only thing holding the popup open.

Takes the larger of the two instead, so both cases hold: the fixed floor
governs the popup, and 100dvh still wins in fullscreen where the window
is taller than the popup height.

Measured with a single-token account. Before, a 300px viewport produced
a 460px layout; now every viewport at or below 600px produces exactly
600px, and 900px still produces 900px. The original check missed this
because it measured at 360x600, where the viewport already equalled the
popup height and masked the absent floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns desktop balance and empty-state behavior with Freighter Mobile while fixing wallet recovery labels and error/fullscreen layouts.

Changes:

  • Adds $0.00 fallbacks and wallet-row loading indicators.
  • Improves funding, fetch-error, and fullscreen states.
  • Corrects imported flags for phrase-derived accounts and adds regression tests.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
extension/src/popup/views/Wallets/index.tsx Preserves wallet controls during fetch errors.
extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx Uses centralized fiat formatting.
extension/src/popup/views/Account/styles.scss Adds notification gutters and fixes margins.
extension/src/popup/views/Account/index.tsx Shows zero totals and restores the Redux public key.
extension/src/popup/views/__tests__/Account.test.tsx Updates zero-total error-state coverage.
extension/src/popup/helpers/formatters.ts Adds the fiat amount formatter.
extension/src/popup/helpers/__tests__/formatters.test.ts Tests formatting and fallback behavior.
extension/src/popup/components/account/WalletRow/styles.scss Stabilizes balance-cell layout.
extension/src/popup/components/account/WalletRow/index.tsx Adds balance spinners and zero fallbacks.
extension/src/popup/components/account/WalletRow/__tests__/WalletRow.test.tsx Covers wallet-row balance states.
extension/src/popup/components/account/NotFundedMessage/index.tsx Shows one network-appropriate funding action.
extension/src/popup/components/account/__tests__/NotFundedMessage.test.tsx Covers funding-action selection and styling.
extension/src/popup/basics/layout/View/styles.scss Extends scrollable layouts to viewport height.
extension/src/background/messageListener/handlers/recoverAccount.ts Stops marking phrase-derived accounts as imported.
extension/src/background/messageListener/__tests__/recoverAccount.test.ts Adds recovery-label regression coverage.
Suppressed comments (1)

extension/src/popup/components/account/WalletRow/index.tsx:95

  • For wallets in the first batch (and therefore every account when there are six or fewer), this spinner is never rendered: fetchData leaves the request in LOADING until batchedFetchBalances finishes its first Promise.all, so the whole-screen loader remains visible and the rows mount only after their totals arrive. Dispatch the initial resolved payload with isFetchingTokenPrices: true before awaiting the batch loop so pending first-batch rows can show the new spinner as described.
        {isTotalLoading ? (
          // SDS `Loader` takes only `size`, so the testid goes on a wrapper.
          <span data-testid="wallet-row-balance-spinner">
            <Loader size="1rem" />
          </span>

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread extension/src/popup/components/account/WalletRow/index.tsx
@CassioMG

Copy link
Copy Markdown
Contributor Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

Co-authored-by: CassioMG <3228151+CassioMG@users.noreply.github.com>

Copilot AI commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved the PR merge conflicts in commit d75cd4d by merging origin/master into this branch and resolving the locale conflicts (en/pt) while keeping the master-side Preferences copy update.

CassioMG and others added 2 commits August 13, 2026 17:48
The conflict resolution on the last master merge restored this key, which
18f381f had removed as unused. It still has no source references — the
only nearby matches are tests asserting a different string, "No hidden
collectibles".

Confirmed by re-running the scanner: it does not re-add the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	extension/src/popup/locales/en/translation.json
#	extension/src/popup/locales/pt/translation.json
@piyalbasu

Copy link
Copy Markdown
Contributor

In the Wallets view, it looks like a particular wallet fails to fetch its USD, balance, we show $0.00. I think that can be a little misleading (and potentially a little scary) for users. It's not actually 0, it's just that we can't show the total value. I'd suggest we try to show -- or some other indicator that there was a failure. I think it's fine to show $0.00 on testnet

@piyalbasu

Copy link
Copy Markdown
Contributor

New test file isn't colocated with its component

Nit / suggestionnot a merge blocker. Cosmetic only; no behavior impact, and it's a one-line file move.

TL;DR: One of the two test files this PR adds sits in a different place than the other. The wallet-row test lives next to the component it covers, but the not-funded-message test is placed a level above its component, in a new folder created just for it. Everything else in this part of the codebase keeps tests next to the code they test, so it'd be nice to have the two new files agree with each other.


Detailed explanation (for agents)

The inconsistency: this PR adds two test files under popup/components/account/ and places them differently.

Colocated with its component (matches convention):

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { Wrapper } from "popup/__testHelpers__";
import { WalletRow } from "popup/components/account/WalletRow";
const PUBLIC_KEY = "GDF3ZEFYPUBLICKEYFORTESTINGONLYAAAAAAAAAAAAAAAAAAAAAAAAA";

One level above its component, in a newly created account/__tests__/ directory:

import React from "react";
import { render, screen } from "@testing-library/react";
import {
MAINNET_NETWORK_DETAILS,
TESTNET_NETWORK_DETAILS,
} from "@shared/constants/stellar";
import { Wrapper } from "popup/__testHelpers__";

The component under test is at popup/components/account/NotFundedMessage/index.tsx, so the colocated path would be account/NotFundedMessage/__tests__/NotFundedMessage.test.tsx.

Why this one is worth a nit rather than a shrug:

  1. It's the stated rule — testing.md line 5: "Test files live in __tests__/ directories alongside the source they test."

    # Testing -- Freighter Extension
    ## Jest Unit Tests
    - Test files live in `__tests__/` directories alongside the source they test
    - Environment: JSDOM (`jest-fixed-jsdom`)
    - Coverage collected from `src/**/*.{ts,tsx,mjs}`
    - Configuration in the root `jest.config.js`

  2. account/__tests__/ did not exist before this PR and now contains exactly one file. Prior to this change, the only test anywhere under popup/components/account/ was colocated:

    import { NETWORK_NAMES } from "@shared/constants/stellar";
    import { getNetworkDisplayName } from "../getNetworkDisplayName";
    const DISPLAY_NAMES = {

    So this isn't following an existing local pattern — it establishes a new non-colocated directory in a subtree that was previously 100% colocated.

  3. The PR disagrees with itself: same parent directory, same PR, two different conventions.

Pre-empting the obvious counter-argument: popup/components/__tests__/ holds 15 test files for components that live in their own subdirectories (AssetDetail.test.tsx, CollectibleDetail.test.tsx, etc.), so non-colocated tests clearly exist in the repo. But that's a different directory one level up, and it's legacy — it doesn't establish a precedent inside account/, where the count of non-colocated tests before this PR was zero.

Suggested fix:

mkdir -p extension/src/popup/components/account/NotFundedMessage/__tests__
git mv extension/src/popup/components/account/__tests__/NotFundedMessage.test.tsx \
       extension/src/popup/components/account/NotFundedMessage/__tests__/NotFundedMessage.test.tsx

That's the whole change — no other edits needed:

  • No import changes. The file imports via absolute module paths (popup/components/account/NotFundedMessage, popup/__testHelpers__), resolved by moduleDirectories: ["node_modules", "<rootDir>/extension/src", "<rootDir>/."] in jest.config.js, so nothing is relative to the file's own location.
  • No jest config change. roots already includes ./extension and the default testMatch picks up __tests__/ at any depth.

Entirely your call if you'd rather keep it as-is; flagging it because the two new files landing in the same PR with different conventions is the kind of thing that quietly becomes the next precedent.

@CassioMG

CassioMG commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

In the Wallets view, it looks like a particular wallet fails to fetch its USD, balance, we show $0.00. I think that can be a little misleading (and potentially a little scary) for users. It's not actually 0, it's just that we can't show the total value. I'd suggest we try to show -- or some other indicator that there was a failure. I think it's fine to show $0.00 on testnet

@piyalbasu Yeah, thinking again about this I think it's best to display -- when there is an error fetching the USD balance so the app is clear about it instead of displaying a misleading (and scary) $0.00 to users on Mainnet, which is different than defaulting to $0.00 on Testnet's Home screen where it's expected to have no price or real-world value. I'll adjust it to display -- in case of error. FYI @aristidesstaffieri @minkyeongshin

This PR added two tests under components/account/ and placed them
inconsistently: WalletRow's sat beside its component, this one a level
above in an account/__tests__/ directory the PR itself created.

Colocation is the documented rule (testing.md: "Test files live in
__tests__/ directories alongside the source they test") and was the only
pattern present under account/ — the one pre-existing test there,
AccountHeader's, is colocated. The new directory held exactly this file,
so leaving it would have made it the precedent for that subtree.

A plain move: the file imports through absolute module paths resolved by
jest's moduleDirectories, and the default testMatch finds __tests__/ at
any depth, so no imports and no config change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CassioMG

Copy link
Copy Markdown
Contributor Author

New test file isn't colocated with its component

Nit / suggestionnot a merge blocker. Cosmetic only; no behavior impact, and it's a one-line file move.

TL;DR: One of the two test files this PR adds sits in a different place than the other. The wallet-row test lives next to the component it covers, but the not-funded-message test is placed a level above its component, in a new folder created just for it. Everything else in this part of the codebase keeps tests next to the code they test, so it'd be nice to have the two new files agree with each other.

Detailed explanation (for agents)

Nice call, fixed in b50168f

@CassioMG CassioMG changed the title feat(balances): align the $0.00 balance UI with mobile + tweaks feat(balances): align the $0.00 Testnet balance UI with mobile + tweaks Aug 14, 2026
CassioMG and others added 7 commits August 14, 2026 12:26
… feed

A missing total has two causes and they deserve different answers. Where
the network prices no tokens, zero is the accurate total. Everywhere else
an absent total means prices or balances could not be read, and "$0.00"
asserts a balance the account may not have — so the hero and the wallets
rows now show "--" there, the same thing the token rows show for a token
with no price.

Mainnet is the only network with a feed, so it alone can produce "--";
the others always read "$0.00", which keeps the common case consistent.

WalletRow takes the network's pricing support as a prop rather than
inferring it from the empty-string sentinel, so the two cases stay
distinguishable at the render site.

Also restores the account-data spy before asserting in the ERROR-state
test. It ran after, so once that assertion changed, the failure left
useGetAccountData mocked for the rest of the file and took three
unrelated onboarding tests down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An account holding nothing has a real total of zero, so the hero shows it
even on a network that prices tokens. Without this an unfunded Mainnet
account priced nothing, which read as an unavailable total and showed the
dash.

A failed fetch still shows the dash: funding is unknown there, so zero
would be a claim rather than a fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tant

The "--" shown when a USD amount cannot be determined was duplicated
across seven files. Extracted as NO_FIAT_VALUE, next to formatFiatAmount:
the two are halves of one decision — format a known amount, or state that
there is none — and several call sites already import from there.

Its doc records the distinction the duplicated literals could not: the
placeholder means "unknown", while "$0.00" asserts a known zero, so an
unfunded account or a network that prices no tokens keeps the zero.

Comments describing what the user sees still say "--"; they are about the
rendered output rather than the symbol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eturns

Four outcomes nested three ternaries deep did not read as the decision it
is. getTotalUsdLabel states each case in order with an early return and a
line on why that answer is right, which is where the zero-versus-unknown
distinction actually lives.

Placed beside getTotalUsd, whose result it formats, so the pair sits
together and the branches are unit-testable without rendering the view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rows were not applying a different policy — the decision had already
been made a layer up. useGetWalletsData formats each account's total
while fetching, so the row received a string and had to infer intent from
which flavour of falsy arrived: "" for a failed account, undefined for
one not fetched.

The hook holds every input the helper wants (isFunded, tokenPrices, the
computed total), so it calls getTotalUsdLabel directly and emits a
finished label. Off Mainnet it fills one in for every account rather than
leaving the map empty, so a missing entry now means exactly one thing:
still loading.

WalletRow renders what it is handed and no longer takes hasPriceFeed. Its
tests drop to spinner-versus-label; which label is right is covered where
that is now decided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useGetTokenPrices reports failure by resolving with `tokenPrices: null`.
That null reached heldToRecord, whose `tokenPrices: ApiTokenPrices = {}`
default only fires on undefined, so it was indexed directly and threw.
The throw escaped the lookup's async effect before its success dispatch,
leaving the state at LOADING — and the picker counts IDLE and LOADING
alike as loading, so "Swap to" span forever instead of falling back to
held tokens.

Guarded at every step rather than only the one that broke:

- useSwapFromData normalizes the fetch result, as useGetSwapAmountData
  already did; its payload declares the field non-null, so the null was
  also a type lie.
- SwapAsset normalizes at both places it feeds the lookup. The ternary
  there guarded the payload's type, not a null price map.
- heldToRecord, balancesToHeldRecords, buildSwapSections and the lookup
  accept `ApiTokenPrices | null` and normalize at the leaf, so a future
  caller cannot reintroduce it.

Present on master; not introduced by this branch.

Covered by a test on balancesToHeldRecords, which fails on the null case
alone if the leaf normalization is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
formatFiatAmount's doc still named a failed price fetch as a reason to
show "$0.00". That case returns the placeholder now, so the doc pointed
readers at the wrong answer. It states what the function does — any
non-numeric input formats as "$0.00" — and hands the unknown case to
NO_FIAT_VALUE.

getTotalUsdLabel's zero branch spells out that a bare formatFiatAmount()
is "$0.00", which the call alone does not show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CassioMG

CassioMG commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@piyalbasu thanks for reviewing. I've updated how we handle "$0.00" vs "--" on Mainnet and Testnet depending on if there was an error fetching prices or not:

  • MAINNET behavior:

    • displays -- as an account total USD balance if the account is funded and there was an error while fetching prices
    • always displays $0.00 for unfunded accounts
  • TESTNET behavior:

    • always displays $0.00 for an account total USD balance regardless of prices erroring since we have no prices (or "real-world" value) on Testnet

I've centralized this total USD balance display logic on this getTotalUsdLabel helper so it's easier to maintain and rationale about. I'll port the same on mobile.

Also while testing the price error scenarios I've found a bug that results in an infinite spinner on the "Swap to" screen (which also load prices) that I've fixed on this other commit.

Below are screenshots so we visualize the different scenarios. FYI @aristidesstaffieri @minkyeongshin

Mainnet happy path (all priced)

Screenshot 2026-08-14 at 16 38 54

Mainnet with prices erroring

Screenshot 2026-08-14 at 16 40 37

Mainnet for unfunded account

Screenshot 2026-08-14 at 16 41 02

Testnet in all cases

Always the same UI regardless of prices erroring since we use no prices there.

Screenshot 2026-08-14 at 16 40 04

Comment thread extension/src/popup/helpers/__tests__/formatters.test.ts Outdated
CassioMG and others added 2 commits August 14, 2026 18:40
Same "failed price fetch" claim the formatter's own JSDoc carried: that
case renders the placeholder, and only genuinely-zero totals reach
formatFiatAmount(). The earlier sweep excluded __tests__, which is how
this one survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CassioMG
CassioMG merged commit 7dcadec into master Aug 17, 2026
11 checks passed
@CassioMG
CassioMG deleted the feature/balance-loading-states branch August 17, 2026 17:03
CassioMG added a commit to stellar/freighter-mobile that referenced this pull request Aug 18, 2026
Ports the extension's getTotalUsdLabel rule (stellar/freighter#2960) so both
clients agree on when a total is a known zero and when it could not be read:

- "--" when the balances fetch failed, or a funded account on a priced
  network resolved no prices
- "$0.00" when the network prices no tokens (testnet/futurenet) or the
  account is unfunded — a real zero, not a stand-in
- the formatted total otherwise, a genuine zero included

The decision lives in one helper (helpers/balances.getTotalUsdLabel, with
NO_FIAT_VALUE in helpers/formatAmount, mirroring the extension's split) and is
used by both the Home header and the wallets list, so the two can never
disagree.

The wallets list needs inputs only its fetch cycle holds (funded state,
whether anything priced, whether the fetch failed), so accountsFiatTotals now
stores a resolved { label, hasError } entry per account instead of a nullable
BigNumber. hasError keeps a failed account eligible for the next retry, and
non-mainnet pre-fills every row with the known zero rather than leaving rows
to interpret a missing entry.

hasDecimals moved from helpers/balances to helpers/formatAmount (next to its
main caller) so the balances -> formatAmount dependency runs one way only,
matching the extension's module layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CassioMG added a commit to stellar/freighter-mobile that referenced this pull request Aug 18, 2026
… vs "--") (#978)

* style(home): match Tokens/Collectibles spinner color to the Home hero

The Tokens and Collectibles tab spinners used the near-white `secondary`
color while the Home fiat-total spinner uses `foreground.primary`, so the
three loading states on the same screen didn't match. Point both tab
spinners at `foreground.primary`.

The Tokens one also read from the hardcoded dark-only `THEME` constant;
it now goes through `themeColors`, so it's theme-aware like the other two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(balances): align "$0.00" vs "--" totals with the extension

Ports the extension's getTotalUsdLabel rule (stellar/freighter#2960) so both
clients agree on when a total is a known zero and when it could not be read:

- "--" when the balances fetch failed, or a funded account on a priced
  network resolved no prices
- "$0.00" when the network prices no tokens (testnet/futurenet) or the
  account is unfunded — a real zero, not a stand-in
- the formatted total otherwise, a genuine zero included

The decision lives in one helper (helpers/balances.getTotalUsdLabel, with
NO_FIAT_VALUE in helpers/formatAmount, mirroring the extension's split) and is
used by both the Home header and the wallets list, so the two can never
disagree.

The wallets list needs inputs only its fetch cycle holds (funded state,
whether anything priced, whether the fetch failed), so accountsFiatTotals now
stores a resolved { label, hasError } entry per account instead of a nullable
BigNumber. hasError keeps a failed account eligible for the next retry, and
non-mainnet pre-fills every row with the known zero rather than leaving rows
to interpret a missing entry.

hasDecimals moved from helpers/balances to helpers/formatAmount (next to its
main caller) so the balances -> formatAmount dependency runs one way only,
matching the extension's module layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(balances): sync a failed balances fetch to the active account's row

The Home header showed "--" while the same account's row in the wallets sheet
showed "$0.00", which is exactly the disagreement this helper exists to prevent.

fetchedPublicKey/fetchedNetwork are stamped only on a successful response, and
clearAccountData nulls them, so a first-ever balances failure — or the first
after an account switch — left the sync guard with no proof the snapshot
belonged to this account. It returned early, the active row never got an entry,
and the row fell back to a confident "$0.00".

A failed fetch now syncs regardless of the stamp. That is safe because
getTotalUsdLabel checks hasError first and never reads the balances snapshot in
that branch, so the guard's real purpose — never attributing a previous
account's numbers to a new one — is untouched. An in-flight fetch is still
awaited, since an error is only worth writing once the request has settled.

Refs PR #978 review comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(balances): drop the now-dead formattedBalance from useTotalBalance

Home reads only totalLabel, so the bare formatted sum had no production
consumers left — it survived just in the test mocks. Leaving it exported is a
footgun given the hook's own docs steer consumers away from it: it reads as a
confident $0.00 when prices are missing, which is the whole reason totalLabel
exists.

Removing it also retires the formatFiatAmount import, and the totalLabel doc no
longer points at a field that isn't there.

Refs PR #978 review comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants