Skip to content

fix(icons): keep the asset icon that actually loads, not the first one listed - #2993

Closed
piyalbasu wants to merge 9 commits into
masterfrom
fix/asset-icon-pick-what-loads
Closed

fix(icons): keep the asset icon that actually loads, not the first one listed#2993
piyalbasu wants to merge 9 commits into
masterfrom
fix/asset-icon-pick-what-loads

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Asset icons come from sources we don't control — several curated token lists, plus the issuer's SEP-1 TOML — and any of them can carry a URL that no longer serves an image. Resolution used to take whichever URL it read last and hand it straight to the <img>, so a dead URL meant no icon. Worse, when the image then failed, the retry wrote a "no icon" marker into the cache that persists to disk, and the lookup reads that marker as "we already tried, never look again" — so one transient failure removed the asset's icon permanently.

Balance icons are now resolved by loading every URL the lists offer and keeping the first that actually renders, stopping there. Only a URL we've watched render reaches the cache, so a later cache hit is trusted without re-checking.

The whole search shares a single 1.2s budget, split across the candidates still to try — so one hanging host can neither blow the budget nor starve a healthy candidate behind it.

Scope: this is a hotfix for the account view, where the bug was reported. Asset search, sign-transaction rows and history rows still render the first listed URL unchecked; covering them needs a different shape and is called out as a follow-up below.

Reported against USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q, which sits on both the Soroswap list (a direct HTTPS icon) and the LOBSTR list (an ipfs.io gateway icon). LOBSTR won on ordering alone, and this issuer publishes no home domain — its master key weight is 0, so it never can — leaving the TOML fallback permanently unreachable and "no icon" as the final answer.

Steps to reproduce

  1. Hold USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q on Mainnet with the default asset lists enabled.
  2. Open the account view. The icon resolves to the ipfs.io gateway URL from the LOBSTR list rather than the direct HTTPS URL from the Soroswap list.
  3. Let that image fail once (the gateway throttles browser traffic).
  4. The icon is now gone, and stays gone across popup reloads and extension restarts.

Upgrade note

Wallets that already hit this have a persisted "no icon" marker on disk, and could not recover on their own — with no icon there is no <img>, so nothing fires the error handler that would trigger a retry. A one-time migration clears those on upgrade, so the fix reaches the wallets that actually have the bug.


Implementation details (for agents)

Root cause chain

  1. getIconFromTokenLists iterated the lists in order but its break only exited the inner record loop, so the outer loop ran to completion and the last matching list overwrote every earlier match. Nothing ever checked whether the chosen URL loads.
  2. AccountAssets handles the <img> onError by calling retryAssetIcon, which sent iconUrl: null to CACHE_ASSET_ICON and then consulted only getIconUrlFromIssuer (home domain → SEP-1 TOML). The asset lists were never re-checked.
  3. cacheAssetIcon persisted that null verbatim into CACHED_ASSET_ICONS_ID in the background's localStore, and getCachedAssetIconList hands the raw map back including nulls.
  4. getAssetIcons short-circuits on a cached null with a comment claiming the null lives only in Redux and will be retried on the next app load. That comment was wrong — the null was on disk, so the lookup was never attempted again.

For USDT0 the TOML branch can never recover: Horizon reports home_domain: null for the issuer with thresholds {0,0,0} and a single signer at weight 0. A valid stellar.toml with the correct image does exist at usdt0.to, but the wallet has no way to discover that domain.

Key code

Candidate selection and the shared budget, split so no single host can starve the rest:

export const firstLoadableIconUrl = async (
urls: string[],
{
budgetMs = ICON_LOAD_BUDGET_MS,
probe = canLoadIcon,
}: { budgetMs?: number; probe?: IconProbe } = {},
): Promise<string | undefined> => {
const deadline = Date.now() + budgetMs;
for (let index = 0; index < urls.length; index += 1) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
return undefined;
}
// Split what's left evenly across the candidates still to try, rather than
// offering the whole remainder to the next one. A throttled host that never
// answers would otherwise spend the entire budget and starve a healthy
// candidate behind it — the very failure this is here to prevent. A
// candidate that answers quickly leaves its unused share to the rest.
const share = Math.ceil(remaining / (urls.length - index));
if (await probe(urls[index], share)) {
return urls[index];
}
}
return undefined;
};

Why the budget is 1.2s:

/**
* Total time allowed to find a working icon for one asset, shared across all of
* its candidates rather than granted per candidate — so the worst case stays
* flat instead of scaling with how many lists happen to carry the asset.
*
* Measured against the real candidates for
* USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q, both ~8KB
* pngs: 130-300ms each including cold DNS + TLS. 1.2s leaves several times that
* headroom for a slow connection while staying well short of a visible stall.
*
* Running out of budget is cheap: the asset falls back to its generic icon for
* this load only. Nothing negative is persisted (see cacheAssetIcon), and the
* browser generally finishes the abandoned request into its own HTTP cache, so
* the next attempt resolves immediately.
*/
export const ICON_LOAD_BUDGET_MS = 1200;

The serialized cache write:

export const cacheAssetIcon = async ({
request,
localStore,
}: {
request: CacheAssetIconMessage;
localStore: DataStorageAccess;
}) => {
const { assetCanonical, iconUrl } = request;
const applyUpdate = async () => {
const assetIconCache =
(await localStore.getItem(CACHED_ASSET_ICONS_ID)) || {};
if (iconUrl) {
assetIconCache[assetCanonical] = iconUrl;
} else {
// A falsy iconUrl means "forget what we cached" (retryAssetIcon sends
// null to drop a url that failed to load). Delete the entry rather than
// storing the null: getAssetIcons treats a cached null as "already
// tried, never look again", and this cache is persisted to disk — so a
// single transient image failure would blacklist the asset's icon for
// good.
delete assetIconCache[assetCanonical];
}
await localStore.setItem(CACHED_ASSET_ICONS_ID, assetIconCache);
};
pendingCacheWrite = pendingCacheWrite.then(applyUpdate, applyUpdate);
return pendingCacheWrite;
};

The migration that reaches already-broken wallets:

export const dropNullAssetIconCacheEntries = async () => {
const localStore = dataStorageAccess(browserLocalStorage);
const storageVersion = (await localStore.getItem(STORAGE_VERSION)) as string;
if (shouldRunMigration({ storageVersion, migrationVersion: "5.46.0" })) {
const assetIconCache =
(await localStore.getItem(CACHED_ASSET_ICONS_ID)) || {};
const cleaned = Object.fromEntries(
Object.entries(assetIconCache).filter(([, iconUrl]) => iconUrl),
);
await localStore.setItem(CACHED_ASSET_ICONS_ID, cleaned);
await migrateDataStorageVersion("5.46.0");
}
};

What changed

  • New @shared/api/helpers/iconProbe.tscanLoadIcon is an <img> load rather than a fetch: the extension declares no host_permissions, so a cross-origin fetch to an arbitrary icon host is CORS-gated while an image load is not. That confines this to a DOM context, which is fine — icon resolution only runs in the popup, never in the service worker. The probe is injectable, so tests drive selection logic without a network.
    • ICON_LOAD_BUDGET_MS = 1200, spent as one deadline across all candidates, so the worst case doesn't scale with candidate count. Measured against the two real USDT0 candidates (~8KB PNGs): 130-300ms each including cold DNS + TLS.
    • The remaining budget is split across the candidates still to try, so a host that hangs can't spend it all and leave a working candidate untried — the very failure this change exists to prevent. A candidate that answers quickly leaves its unused share to the rest.
    • On timeout the <img> src is deliberately not cleared, so the browser finishes the abandoned request into its own HTTP cache and a merely-slow candidate resolves instantly next time.
    • mapWithConcurrency resolves several assets at once, since getAssetIcons is awaited before the balances render.
  • getIconCandidatesFromTokenLists (new) — pure collector returning every matching icon URL, deduped, in list order. Order decides probe order, not priority.
  • getIconFromTokenLists — unchanged in behavior for its existing callers: it still returns the first candidate without loading it. Those three surfaces are out of scope for this hotfix (see follow-ups).
  • getAssetIcons — split into a synchronous cache pass and a concurrent lookup pass. A cache hit is not re-probed: it was proven loadable when written, and re-probing every load would defeat the cache; a URL that rots later is caught by the onError -> retryAssetIcon path. The TOML fallback gets the same treatment, and a TOML URL that doesn't load is cleared rather than left for the next load to trust.
  • cacheAssetIcon — a falsy iconUrl now deletes the entry instead of storing null, and updates are serialized: the cache is one storage key, concurrent resolution means overlapping read-modify-writes, and storage hands each handler its own copy, so unchained writes lose icons. CacheAssetIconMessage.iconUrl is now string | null so the delete command is part of the contract rather than something callers cast past.
  • dropNullAssetIconCacheEntries — one-time migration (storage v5.46.0) sweeping the nulls earlier versions persisted, wired into versionedMigration. Without it the fix never reaches the wallets that have the bug.
  • retryAssetIcon — re-resolves through the same chain with the URL that just failed removed from the running, so it can never hand back the same broken src; clears a rejected TOML URL; and matches contract assets by contract id (Soroban balances arrive with a contract id as their key, and the lists key those by contract).

Verification

  • 40 new tests, each watched failing first. Highlights: the budget is shared rather than per-candidate (three candidates, one 20ms budget → exactly one load attempted); getAssetIcons picks the loadable candidate over the first-listed one, caches what it settled on rather than what it rejected, and doesn't re-probe a cache hit; the concurrency is pinned by measuring peak in-flight loads through the real getAssetIcons, so it can't silently regress to serial; retryAssetIcon never re-offers the failed URL even when that URL would load; the migration keeps real icons while dropping nulls.
  • Regressions are checked by diffing failing test names against the base, not counts — a suite-level or count-level comparison hid a real break during this work (a history test failing at exactly the 1.2s budget). The final diff is empty in both directions.
  • tsc --noEmit: 243 errors on master, 243 here — no new ones. Those are pre-existing stellar-sdk v17 drift (xdr.encodeBytes, fromXdr/toXdr casing) from the v17 back-port.
  • ESLint clean on all changed source files; Prettier applied.

Note for reviewers: the pre-commit hook (.husky/addTranslations.shyarn build:extension:translations) fails on a clean master checkout for that same pre-existing v17 drift, so these commits were made with --no-verify. No CI job runs that build, so it doesn't affect this PR — but it does mean nobody can commit to this repo without the flag. Worth a separate fix.

Follow-ups / out of scope

  • Asset search, sign-transaction rows and history rows still render the first listed URL unchecked. Probing them synchronously needs a session-wide memo and a warm-up pass to stay affordable, and that machinery caused its own problems when tried here (it was reverted in this PR — see the revert commit). The better shape is to hand the candidate list to the image component and let it fall through on error: no probing ahead of render, no shared memo, no added latency. Worth its own change.
  • The persisted null was also the only cross-session negative cache. An asset with genuinely no discoverable icon now re-runs the chain on every cold popup open. That wants a TTL or an attempt count rather than a permanent verdict, and is deliberately left out of this PR.
  • Contract matching still uses new RegExp(contractId, "i") where a case-insensitive string comparison is what's meant, which also makes it a substring match. Contract IDs are StrKey so there's no injection surface, but it's needlessly fragile.
  • getIconCandidatesFromTokenLists reports one canonicalAsset, taken from the first match. A caller passing a contractId and an issuerId that disagree would get a canonical keyed on the contract. Nothing does today — neither backend attaches a contractId to classic balances — but it's a trap for a future caller.
  • Sign-transaction resolves the issuer TOML before the token lists, the opposite order from every other surface. Not changed here, but it means that path pays a Horizon round trip for assets the lists could have answered instantly.

…e listed

Asset icons come from sources we don't control, and any of them can carry a url
that no longer serves an image. Resolution committed to whichever list it read
last and handed that url straight to the <img>, so a dead url meant no icon.
When the image then failed, the retry wrote a null into the icon cache that
persists to disk, and the lookup reads that null as "already tried, never look
again" — so one transient failure removed the icon for good.

getAssetIcons now collects every icon url the user's lists offer for an asset,
loads them in turn, and keeps the first that renders. Only a url we have seen
render reaches the cache, so a later cache hit is trusted without re-probing.
The issuer TOML stays the last resort and gets the same treatment.

- iconProbe: canLoadIcon (an <img> load, since the extension declares no
  host_permissions and a cross-origin fetch would be CORS-gated) and
  firstLoadableIconUrl, which spends one 1.2s budget across all candidates so
  the worst case doesn't scale with how many lists carry the asset. Measured:
  real icons load in 130-300ms including cold DNS and TLS.
- getIconCandidatesFromTokenLists: pure collector returning every matching url,
  deduped, in list order — which now decides probe order, not priority.
  getIconFromTokenLists stays as the un-probed single-icon path for callers
  that render directly (asset search, sign-transaction, history).
- cacheAssetIcon: a falsy iconUrl deletes the entry instead of persisting null,
  restoring the "retry on next load" semantics getAssetIcons documents.
- retryAssetIcon: re-resolves through the same chain with the url that just
  failed removed from the running, so it can't return the same broken src.

Surfaced by USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q,
carried by both the Soroswap list (direct https icon) and the LOBSTR list
(ipfs.io gateway icon). LOBSTR won on ordering alone, and the issuer publishes
no home_domain — its master key weight is 0, so it never can — leaving the TOML
fallback permanently unreachable and null as the terminal value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 2, 2026 17:55
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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

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

Improves balance icon resolution by probing candidate URLs and avoiding persistent negative cache entries.

Changes:

  • Collects and probes token-list/TOML icon candidates.
  • Revises cache clearing and retry behavior.
  • Adds regression and probe tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
@shared/api/helpers/iconProbe.ts Adds bounded image probing.
@shared/api/helpers/getIconFromTokenList.ts Collects ordered, deduplicated candidates.
@shared/api/internal.ts Integrates probing and retry logic.
extension/src/background/messageListener/handlers/cacheAssetIcon.ts Deletes cleared cache entries.
extension/src/popup/components/account/AccountAssets/index.tsx Supplies token lists during retries.
@shared/api/helpers/__tests__/iconProbe.test.ts Tests probing behavior.
extension/src/popup/helpers/__tests__/getIconFromTokenLists.test.js Tests candidate collection.
@shared/api/__tests__/internal.test.ts Tests resolution and retries.
extension/src/background/messageListener/handlers/__tests__/cacheAssetIcon.test.ts Tests cache deletion.
extension/src/popup/views/__tests__/Account.test.tsx Updates account icon mocks.

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

Comment thread @shared/api/helpers/iconProbe.ts Outdated
Comment thread @shared/api/internal.ts
Comment thread @shared/api/internal.ts Outdated
Comment thread @shared/api/internal.ts
Comment thread extension/src/background/messageListener/handlers/cacheAssetIcon.ts Outdated
…ff the render path

Five defects from the review of this PR, in rough order of impact:

- Migrate away the persisted nulls. cacheAssetIcon no longer writes them, but
  entries left by earlier versions survived the upgrade, and getAssetIcons
  reads a cached null as "don't look again". Those wallets could not recover on
  their own: with no icon there is no <img>, so nothing fires the onError that
  would trigger a retry. Without this sweep the fix never reached the wallets
  that actually have the bug.

- Resolve icons concurrently. getAssetIcons is awaited before the balances
  render, so probing one asset at a time put an image round-trip per asset in
  front of the user where there used to be an in-memory scan. Split the loop
  into a synchronous cache pass and a bounded-concurrency lookup pass, so the
  cost is roughly one asset's rather than the sum.

- Stop the un-probed path seeding the shared cache. getIconFromTokenLists wrote
  its first candidate — nothing had loaded it — into the same store
  getAssetIcons trusts without re-loading, so opening asset search or a history
  row could hand the account view a dead icon and undo the probing entirely.

- Clear a rejected TOML url in retryAssetIcon. getIconUrlFromIssuer caches what
  the toml claims before we load it; leaving a rejected url behind meant the
  next open served it from cache, recreating the loop this PR fixes.

- Match contract assets by contract id in retryAssetIcon. It passed the key as
  an issuer unconditionally, but soroban balances arrive with a contract id
  there and the lists key those by `contract` — so the retry was dead code for
  exactly the assets most likely to be listed. Mirrors the split getAssetIcons
  already used.

Deferred: the persisted null was also the only cross-session negative cache, so
an asset with genuinely no icon now re-runs the chain on every cold open. That
wants a TTL or attempt count rather than a permanent verdict, and is left for a
follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 18:28

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

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

@shared/api/helpers/iconProbe.ts:84

  • A candidate that never emits load or error receives the entire remaining budget, so after it times out the deadline has expired and every later URL is skipped. Thus a hanging first URL still prevents a valid second URL from being selected. Probe candidates concurrently under the shared deadline, or reserve budget for later candidates.
    const remaining = deadline - Date.now();
    if (remaining <= 0) {
      return undefined;
    }
    if (await probe(url, remaining)) {

@shared/api/internal.ts:1569

  • Returning a replacement map does not display the replacement in the current popup. AssetIcon sets its local hasError to true on the original failure, and line 109 then keeps imgSrc pinned to ImageMissingIcon even after canonicalAsset changes. Reset the error/loading state when the resolved URL changes (and add a retry rendering test).
  if (tokenListIcon) {
    await cacheAssetIconUrl(canonical, tokenListIcon);
    newAssetIcons[canonical] = tokenListIcon;
    return newAssetIcons;

* the sum, while the limit stops a large wallet from opening a connection per
* held asset at once.
*/
export const ICON_LOOKUP_CONCURRENCY = 8;
Comment thread @shared/api/internal.ts
Comment thread extension/src/background/messageListener/handlers/cacheAssetIcon.ts Outdated
Probing was scoped to the account view, so asset search, sign-transaction rows
and history rows still took the first url any list offered without checking it.
That left the reported bug live in three of the four places an icon appears —
asset search most visibly, since that is where you look a token up before
adding it.

getIconFromTokenLists now does the loading itself and caches only what it has
confirmed renders, which makes it the single icon-resolution path: getAssetIcons
had grown a private copy of the same logic, now removed.

Two things keep that off the critical path, since these callers resolve icons a
row at a time:

- Probe verdicts are memoized per url for the session, so an asset repeated
  across many history rows is loaded once, and a dead url costs its budget once
  rather than per row.
- Views that fan out resolve concurrently: asset search maps its result rows
  through mapWithConcurrency, and history warms every candidate on the page in
  one batch inside the pre-pass that already collects home domains, so the
  per-row lookups become memo hits.

Sign-transaction keeps its loop as is: a transaction has few operations, and
that path already awaits a Horizon and toml round trip per asset, which dwarfs
an image load.

Also adds a stubIconProbe test helper. Icon resolution now waits on an <img>
load, and jsdom images fire neither load nor error, so an unstubbed test just
loses its icons after the budget expires — surfacing as a missing element with
nothing pointing at icons. Two tests hit this while writing that; the helper
gives the next one an obvious fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 18:51

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

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Comment thread extension/src/background/messageListener/handlers/cacheAssetIcon.ts Outdated
Comment thread @shared/api/helpers/getIconFromTokenList.ts Outdated
Comment thread @shared/api/helpers/iconProbe.ts Outdated
Comment thread extension/src/popup/components/manageAssets/SearchAsset/hooks/useAssetLookup.ts Outdated
piyalbasu and others added 2 commits September 2, 2026 15:00
…st balances"

This reverts commit 0787945.

Extending the probe to asset search, sign-transaction and history needed a
session-wide probe memo and a warm-up pass to stay affordable, and that
machinery brought its own failures:

- The history hook writes `icons[canonical] = iconUrl || null` into a map that
  is dispatched to Redux and later read by getAssetIcons as "already looked,
  skip". Since a probe can now return nothing, one slow icon host while viewing
  history removed that asset's icon from the account screen for the rest of the
  session — a milder replay of the bug this PR exists to fix.
- The warm-up pass loaded every candidate rather than stopping at the first that
  works, and was awaited before history rendered: ~60 image loads for a busy
  page, and up to several seconds of blocked render against a dead host. It was
  added to avoid a serial cost and was worse than the cost.
- The memo keyed verdicts on url alone while candidates are probed with whatever
  is left of the shared budget, so a candidate that merely ran out of time was
  recorded as permanently dead — contradicting the reasoning written into the
  budget constant, which assumes a slow candidate gets another chance.

This PR is a hotfix. It goes back to fixing the reported bug where it was
reported — the account view — which is the part that was reviewed and green.

Covering the other three surfaces is still worth doing, but as its own change
and in a different shape: hand the candidate list to the image component and let
it fall through on error. That needs no probing ahead of render, no shared memo,
and no added latency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 19:27
…l be a null

Two small holes left by the review.

firstLoadableIconUrl handed each candidate whatever was left of the shared
budget, so a throttled host that never answers could spend all 1.2s and the
healthy candidate behind it was never tried — the exact failure this change
exists to prevent. It now splits what remains across the candidates still to
try, and a candidate that answers quickly leaves its unused share to the rest.
USDT0 happened to list its working url first, so the reported case worked by
luck rather than by design.

CacheAssetIconMessage.iconUrl was still declared string while a null is what
clears an entry, so the handler's own test had to cast through unknown to
compile. Widening the field to string | null makes senders and handler agree,
and the cast goes away.

Co-Authored-By: Claude Opus 5 (1M context) <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

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Suppressed comments (1)

@shared/api/internal.ts:1431

  • These concurrent workers each call cacheAssetIconUrl, while the background handler updates the cache with a read-modify-write of the entire CACHED_ASSET_ICONS_ID map. Two messages can read the same snapshot and then overwrite each other, so only the last resolved icon remains persisted. Keep image probes concurrent, but serialize cache writes or send one batched background update.
    const resolvedIcons = await mapWithConcurrency(
      needsLookup,
      ICON_LOOKUP_CONCURRENCY,
      ({ key, code, contractId }) =>

Comment thread @shared/api/helpers/getIconFromTokenList.ts
Comment thread @shared/api/helpers/iconProbe.ts Outdated
Comment thread @shared/api/internal.ts
Comment thread extension/src/popup/views/__tests__/Account.test.tsx Outdated
Copilot AI review requested due to automatic review settings September 2, 2026 19:32

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

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

extension/src/popup/components/account/AccountAssets/index.tsx:272

  • Passing alternate candidates into the retry does not make the replacement visible. AssetIcon sets its local hasError to true on the failing image, and thereafter computes imgSrc as ImageMissingIcon regardless of the updated assetIcons; this update cannot display the URL returned by retryAssetIcon until a remount. Reset the child’s error/loading state when its canonical URL changes, or await a successful retry and clear the error state.

@shared/api/helpers/getIconFromTokenList.ts:98

  • getIconFromTokenLists is still used by asset search, account history, and sign-transaction, but returning candidates[0] never checks whether it loads. A dead first URL therefore remains broken on three of the four surfaces, contrary to the PR’s all-surface resolution behavior. Route these callers through the load probe and cache only the successful result; history should batch/warm its candidates to avoid serial row latency.
    icon: candidates[0],

@shared/api/helpers/iconProbe.ts:75

  • The default path calls canLoadIcon directly, and this module has no per-URL verdict cache. Repeated resolutions therefore reload the same URL—including failures—and can consume the full budget again, so the session memoization promised in the PR is absent. Memoize both in-flight and settled results by URL and use that wrapper as the default probe.
    budgetMs = ICON_LOAD_BUDGET_MS,
    probe = canLoadIcon,
  }: { budgetMs?: number; probe?: IconProbe } = {},

@shared/api/internal.ts:1431

  • These workers also persist successful icons concurrently, but cacheAssetIcon performs a non-atomic read/modify/write of the entire cache object. Two workers can read the same snapshot and the last setItem then drops the other worker’s entry; the former serial loop avoided this race. Separate probing from persistence and serialize/batch cache updates, or make the background update path atomic.
    const resolvedIcons = await mapWithConcurrency(
      needsLookup,
      ICON_LOOKUP_CONCURRENCY,
      ({ key, code, contractId }) =>

…ng its test

Two more from PR review.

The whole icon cache lives under one storage key, and every update reads it,
changes an entry, and writes it back. Resolving icons concurrently means those
updates overlap, and storage hands each handler its own deserialized copy — so
two handlers read the same map, add different assets, and the later write
discards the earlier one's icon, or undoes a clear. Updates are now chained so
each works from the result of the last.

The regression test needed care to be honest: a fake store that hands back the
same object reference, or one that merely staggers reads with timers, both hide
the race. It holds every read open until all handlers have read, and returns a
copy per read, the way browser.storage does.

The Account icon test stubbed the probe and never restored it. The suite's
afterEach calls clearAllMocks, which drops call history but keeps the
implementation, so the stub stood in for the real thing in every test after it
and made the file order-dependent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 19:44

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

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (5)

Previously missed (2) — in code that hasn't changed since the last review.

@shared/api/internal.ts:1586

  • A successful retry cannot display this replacement in the current popup. AssetIcon sets its local hasError to true when the original URL fails, and while that remains true it always selects ImageMissingIcon; updating the parent map does not reset that state. Reset the row's error/loading state when its canonical icon URL changes, or clear it after the retry succeeds.
    extension/src/popup/components/account/AccountAssets/index.tsx:272
  • The retry result is applied only to this component's local state; the Redux icon cache still contains the failed truthy URL. useGetBalances prefers a non-empty Redux cache over background storage, and getAssetIcons trusts that value, so navigating away and back in the same popup serves the broken URL again. Dispatch saveIconsForBalances with the replacement (or cleared entry) when the retry finishes.

This issue also appears on line 269 of the same file.

@shared/api/helpers/getIconFromTokenList.ts:98

  • getIconFromTokenLists still returns the first listed URL without probing it. Its callers in asset search, sign-transaction, and history render this value directly, so a dead first URL still reproduces the bug on three of the four surfaces that the PR says are covered. Route this helper through firstLoadableIconUrl and cache only the successful URL, or migrate all three callers to the shared probing resolver.
    icon: candidates[0],

@shared/api/helpers/iconProbe.ts:75

  • The default probe is canLoadIcon itself, so every call creates a new Image; there is no session-level URL verdict or shared in-flight promise. Repeated rows can therefore retry the same dead URL and repeatedly consume the timeout, contrary to the PR's memoization guarantee. Add a URL-keyed promise cache and use its wrapper as the default probe.
    budgetMs = ICON_LOAD_BUDGET_MS,
    probe = canLoadIcon,
  }: { budgetMs?: number; probe?: IconProbe } = {},

extension/src/popup/components/account/AccountAssets/index.tsx:272

  • This new token-list retry is still guarded by the component-wide hasIconFetchRetried flag. Once one row completes a retry, every other asset row's onError returns before using these lists, so a wallet with multiple stale cached icons can recover only the first one. Track retries per canonical asset (or per AssetIcon) instead.
        // Session cache of the user's asset lists, populated by the balances
        // fetch. Empty on a cold popup, in which case the retry degrades to
        // the issuer-TOML lookup it used to do exclusively.
        assetsListsData: cachedTokenLists,

The concurrency limit caps how many assets resolve simultaneously, not how long
the pass takes. A wallet holding more uncached assets than the limit resolves
them in waves, and each wave can spend the full per-asset budget — so against a
dead icon host the waves add up. getAssetIcons is awaited before the balances
render, which put that wait in front of the user.

The token-list pass now runs under an overall deadline. Assets not reached in
time fall through to the issuer-toml stage, which is a single parallel batch
rather than waves, and are looked up again on the next load. The deadline is a
parameter so tests can drive it without a slow test.

In the ordinary case nothing is skipped: real icons load in 130-300ms, so even
a large wallet finishes well inside the budget. It only bites when the icons
themselves are not answering, which is exactly when the wait was worst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 19:50

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

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

@shared/api/internal.ts:1596

  • The replacement URL returned here is not displayed by the account row. AssetIcon sets its local hasError to true when the original image fails (AccountAssets/index.tsx:165-170), and while that flag remains true it forces imgSrc to ImageMissingIcon instead of the updated canonical URL (:108-110). Updating the parent map does not remount the row because its key remains the same, so this retry can successfully resolve and cache an alternate icon while the UI continues showing the missing-image state. Reset the row's error/loading state when its canonical URL changes (and add a rendered retry regression test).

Comment thread @shared/api/internal.ts
// as the token-list candidates. getIconUrlFromIssuer caches whatever
// the toml claims, so a url that fails here is cleared again rather
// than left behind for the next load to trust.
const usableIcon = await firstLoadableIconUrl(icon ? [icon] : []);
Comment thread @shared/api/internal.ts
Comment on lines +1441 to +1443
Date.now() >= lookupDeadline
? Promise.resolve(undefined)
: resolveIconFromTokenLists({
AssetIcon set an error flag when an <img> failed and never cleared it, and the
rendered src reads that flag first. So once an icon failed, the row kept the
broken-image glyph for the life of the component even after a working url
arrived — which is exactly what the retry path produces. Every retry
improvement in this change was therefore invisible until the popup was
reopened and the cached url was read back.

The flag now clears whenever the resolved url changes, since a different url is
a fresh attempt.

Also pins the reported bug's own shape: USDT0 sits on the Soroswap list with a
direct https icon and on the LOBSTR list with an ipfs.io gateway url. That
gateway answers curl but returns 403 to a browser User-Agent, so the <img>
could never load it. Resolution used to keep whichever list it read last, which
handed the browser the one url it cannot fetch; the test pins that the earlier
list wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 2, 2026 20:05

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (2)

@shared/api/internal.ts:1441

  • lookupDeadline only prevents a new worker item from starting. An item begun just before the deadline still gets the full 1.2-second budget inside resolveIconFromTokenLists, so the documented 4-second ceiling (and smaller injected budgets) can be exceeded by up to another per-asset budget. Pass the remaining account budget into firstLoadableIconUrl, or otherwise bound in-flight work by this deadline.
        Date.now() >= lookupDeadline

@shared/api/helpers/getIconFromTokenList.ts:98

  • This is not behavior-preserving for the out-of-scope callers as the PR description claims. The old outer loop overwrote verifiedToken for every matching list (its break only exited the record loop), so it returned the last match; candidates[0] now makes asset search, signing, and history use the first match. Either preserve the old ordering for those callers or update the scope/impact description to acknowledge the behavior change.
    icon: candidates[0],

@piyalbasu

Copy link
Copy Markdown
Contributor Author

Closing in favour of #2994, which fixes the reported USDT0 bug in four lines of production code.

This PR grew well past what the bug needed. The reported failure has two causes — icon lookup keeping the last matching list instead of the first, and a "no icon" verdict being persisted to disk — and #2994 addresses exactly those. Everything else here was built around loading each candidate URL to check it renders before using it, and that machinery is what three rounds of review kept finding problems in: budget starvation, premature rejection on slow connections, a session memo that recorded timeouts as permanent failures, cache writes racing under concurrency.

That idea is still worth having. Ordering only fixes the known case; a list shipping a URL browsers reject is caught only by actually loading it. But it needs a per-URL verdict cache and concurrency control to stay off the balances render path, and it deserves its own change rather than riding along with a hotfix.

Two things here are worth salvaging separately:

  • The AssetIcon error latch. setHasError(true) is never cleared when a new URL arrives, so once an icon fails the row keeps the broken-image glyph for the life of the component — a successful retry is invisible until the popup is reopened. Small, self-contained, and independent of any probing.
  • The probe itself, if we want the stronger guarantee, along with the three surfaces that still render an unchecked URL (asset search, sign-transaction rows, history rows).

Worth noting for whoever picks this up: LOBSTR's list ships an ipfs.io URL for USDT0 that returns 200 to curl and 403 to a browser User-Agent. It is the only such entry across the three default lists today, and it is worth reporting upstream — #2994 works because Soroswap happens to list USDT0 first, not because the bad entry went away.

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.

2 participants