From cc297bc4fdcbeabc9afb27f55eeae1ac21da1b52 Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Thu, 11 Jun 2026 16:03:35 -0700 Subject: [PATCH 1/6] refactor(send): unify Tokens/Collectibles loading and error states Replace the two independent loading spinners and error fields in the Send flow's asset selection (TokensCollectiblesInline) with a single page-level spinner and a single error field. - One spinner shows until both tokens and collectibles finish loading. - One error field: a token error takes precedence over a collectibles error, and any error replaces the whole view rather than rendering the succeeded section alongside the error. - The "Tokens" and "Collectibles" section headers are now hidden when the user has none of that asset type; a fallback message covers the (upstream-unreachable) case where both are empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/TokensCollectiblesInline.tsx | 192 +++++++++++------- src/i18n/locales/en/translations.json | 3 +- src/i18n/locales/pt/translations.json | 3 +- 3 files changed, 119 insertions(+), 79 deletions(-) diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index f5f37a595..97db8372a 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -11,6 +11,7 @@ import { import { useCollectiblesStore } from "ducks/collectibles"; import { pxValue } from "helpers/dimensions"; import useAppTranslation from "hooks/useAppTranslation"; +import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; import React from "react"; @@ -45,39 +46,91 @@ export const TokensCollectiblesInline: React.FC< }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - const isLoading = useCollectiblesStore((state) => state.isLoading); - const error = useCollectiblesStore((state) => state.error); + + // Tokens state, lifted from the same hook BalancesList uses internally so we + // can drive a single page-level spinner/error. + const { isLoading: tokensLoading, error: tokensError, noBalances } = + useBalancesList({ publicKey, network }); + + // Collectibles state. + const collectiblesLoading = useCollectiblesStore((state) => state.isLoading); + const collectiblesError = useCollectiblesStore((state) => state.error); const { visibleCollectibles } = useFilteredCollectibles(); - const renderCollectiblesContent = () => { - if (isLoading) { + const hasTokens = !noBalances; + const hasCollectibles = visibleCollectibles.length > 0; + + // Single spinner: keep it up until both sources finish their initial load. + // Gating on the "no data yet" condition (rather than raw isLoading) avoids + // the spinner flashing over already-rendered content on background refreshes. + const showSpinner = + (tokensLoading && noBalances) || + (collectiblesLoading && !hasCollectibles); + + // Single error field: a token error takes precedence over a collectibles + // error, and any error replaces the whole view rather than rendering the + // section that succeeded. + let errorMessage: string | null = null; + if (tokensError) { + errorMessage = t("balancesList.error"); + } else if (collectiblesError) { + errorMessage = t("collectiblesGrid.error"); + } + + const renderCollectibles = () => + visibleCollectibles.map((collection) => ( + + + + {collection.collectionName} + + + + {collection.items.length} + + + + {collection.items.map((item) => ( + + onCollectiblePress?.({ + collectionAddress: item.collectionAddress, + tokenId: item.tokenId, + }) + } + > + + + + + + {item.name || `${collection.collectionName} #${item.tokenId}`} + + + ))} + + )); + + const renderContent = () => { + if (showSpinner) { return ( ); } - if (error) { + if (errorMessage) { return ( - - {t("collectiblesGrid.error")} - - - ); - } - - if (visibleCollectibles.length === 0) { - return ( - - - - {t("collectiblesGrid.empty")} + + {errorMessage} ); @@ -85,43 +138,52 @@ export const TokensCollectiblesInline: React.FC< return ( <> - {visibleCollectibles.map((collection) => ( - - - - {collection.collectionName} + {hasTokens && ( + <> + + + + {t("balancesList.title")} - - - {collection.items.length} + + + + + )} + + {hasCollectibles && ( + <> + + + + {t("collectiblesGrid.title")} - {collection.items.map((item) => ( - - onCollectiblePress?.({ - collectionAddress: item.collectionAddress, - tokenId: item.tokenId, - }) - } - > - - - - - - {item.name || `${collection.collectionName} #${item.tokenId}`} - - - ))} + {renderCollectibles()} + + )} + + {!hasTokens && !hasCollectibles && ( + + + + {t("transactionTokenScreen.empty")} + - ))} + )} ); }; @@ -133,31 +195,7 @@ export const TokensCollectiblesInline: React.FC< keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingHorizontal: pxValue(DEFAULT_PADDING) }} > - - - - {t("balancesList.title")} - - - - - - - - - {t("collectiblesGrid.title")} - - - - {renderCollectiblesContent()} + {renderContent()} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e736f2cac..75a93b047 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -716,7 +716,8 @@ "minimumReceived": "Minimum received" }, "transactionTokenScreen": { - "title": "Sending" + "title": "Sending", + "empty": "No tokens or collectibles to send" }, "transactionSettings": { "title": "Transaction settings", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a6fa7e315..c69896507 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -680,7 +680,8 @@ "minimumReceived": "Mínimo recebido" }, "transactionTokenScreen": { - "title": "Enviando" + "title": "Enviando", + "empty": "Nenhum token ou colecionável para enviar" }, "transactionSettings": { "title": "Configurações da transação", From 197af4aec075fcfc55c3ac32e7ef98cb2311c143 Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Thu, 11 Jun 2026 16:52:08 -0700 Subject: [PATCH 2/6] test(send): update TokensCollectiblesInline tests for unified states Sync the component tests with the unified loading/error refactor: mock useBalancesList, rename the spinner testID, and cover the new single-spinner, token-error-precedence, section-hiding, and combined empty-state behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TokensCollectiblesInline.test.tsx | 163 +++++++++++++----- 1 file changed, 117 insertions(+), 46 deletions(-) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index 2735ef07f..55f833a25 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline"; import { NETWORKS, TransactionContext } from "config/constants"; import { useCollectiblesStore } from "ducks/collectibles"; +import { useBalancesList } from "hooks/useBalancesList"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; import React from "react"; import { @@ -42,9 +43,10 @@ jest.mock("hooks/useAppTranslation", () => ({ t: (key: string) => ({ "balancesList.title": "Tokens", + "balancesList.error": "Error loading balances", "collectiblesGrid.title": "Collectibles", "collectiblesGrid.error": "Error loading collectibles", - "collectiblesGrid.empty": "No collectibles", + "transactionTokenScreen.empty": "No tokens or collectibles to send", })[key] || key, }), })); @@ -65,82 +67,159 @@ jest.mock("ducks/collectibles", () => ({ useCollectiblesStore: jest.fn(), })); +jest.mock("hooks/useBalancesList", () => ({ + useBalancesList: jest.fn(), +})); + jest.mock("hooks/useFilteredCollectibles", () => ({ useFilteredCollectibles: jest.fn(), })); const mockUseCollectiblesStore = useCollectiblesStore as unknown as jest.MockedFunction; +const mockUseBalancesList = + useBalancesList as unknown as jest.MockedFunction; const mockUseFilteredCollectibles = useFilteredCollectibles as unknown as jest.MockedFunction; -const setupCollectibleState = ({ - isLoading = false, - error = null, +const setupState = ({ + // Tokens + tokensLoading = false, + tokensError = null, + noBalances = false, + // Collectibles + collectiblesLoading = false, + collectiblesError = null, visibleCollectibles = [], }: { - isLoading?: boolean; - error?: string | null; + tokensLoading?: boolean; + tokensError?: string | null; + noBalances?: boolean; + collectiblesLoading?: boolean; + collectiblesError?: string | null; visibleCollectibles?: any[]; }) => { + mockUseBalancesList.mockReturnValue({ + isLoading: tokensLoading, + error: tokensError, + noBalances, + }); mockUseCollectiblesStore.mockImplementation((selector: any) => - selector({ isLoading, error }), + selector({ isLoading: collectiblesLoading, error: collectiblesError }), ); mockUseFilteredCollectibles.mockReturnValue({ visibleCollectibles }); }; +const renderComponent = (props = {}) => + render( + , + ); + describe("TokensCollectiblesInline", () => { beforeEach(() => { jest.clearAllMocks(); }); - it("shows loading spinner while collectibles are loading", () => { - setupCollectibleState({ isLoading: true, visibleCollectibles: [] }); + it("shows a single spinner while tokens are still loading", () => { + setupState({ tokensLoading: true, noBalances: true }); - render( - , - ); + renderComponent(); - expect(screen.getByTestId("collectibles-inline-spinner")).toBeTruthy(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); }); - it("shows collectibles error state", () => { - setupCollectibleState({ error: "failed", visibleCollectibles: [] }); + it("shows a single spinner while collectibles are still loading", () => { + setupState({ collectiblesLoading: true, visibleCollectibles: [] }); - render( - , - ); + renderComponent(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + }); + + it("shows the collectibles error when only collectibles fail", () => { + setupState({ collectiblesError: "failed", noBalances: true }); + + renderComponent(); + + expect( + screen.getByTestId("tokens-collectibles-inline-error"), + ).toBeTruthy(); expect(screen.getByText("Error loading collectibles")).toBeTruthy(); }); - it("shows collectibles empty state", () => { - setupCollectibleState({ visibleCollectibles: [] }); + it("prefers the token error over the collectibles error", () => { + setupState({ + tokensError: "token-failed", + collectiblesError: "collectibles-failed", + }); + + renderComponent(); + + expect(screen.getByText("Error loading balances")).toBeTruthy(); + expect(screen.queryByText("Error loading collectibles")).toBeNull(); + }); + + it("shows the combined empty fallback when there are no tokens or collectibles", () => { + setupState({ noBalances: true, visibleCollectibles: [] }); + + renderComponent(); + + expect( + screen.getByText("No tokens or collectibles to send"), + ).toBeTruthy(); + }); + + it("hides the collectibles section when there are no collectibles", () => { + setupState({ noBalances: false, visibleCollectibles: [] }); + + renderComponent(); + + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.queryByText("Collectibles")).toBeNull(); + expect( + screen.queryByText("No tokens or collectibles to send"), + ).toBeNull(); + }); + + it("hides the tokens section when there are no balances", () => { + setupState({ + noBalances: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); - render( - , - ); + renderComponent(); - expect(screen.getByText("No collectibles")).toBeTruthy(); + expect(screen.queryByTestId("balances-list-token-row")).toBeNull(); + expect(screen.getByText("Collectibles")).toBeTruthy(); }); it("forwards token and collectible press handlers", () => { const onTokenPress = jest.fn(); const onCollectiblePress = jest.fn(); - setupCollectibleState({ + setupState({ visibleCollectibles: [ { collectionAddress: "CABC", @@ -157,15 +236,7 @@ describe("TokensCollectiblesInline", () => { ], }); - render( - , - ); + renderComponent({ onTokenPress, onCollectiblePress }); fireEvent.press(screen.getByTestId("balances-list-token-row")); expect(onTokenPress).toHaveBeenCalledWith("native"); From e6aa0f0c9170d8c665a75f3a9ef32268997e3742 Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Thu, 11 Jun 2026 17:07:44 -0700 Subject: [PATCH 3/6] fix(send): prioritize error over spinner in TokensCollectiblesInline Per Codex review: when one source errors while the other is still doing its initial load, the spinner branch ran before the error branch, masking the error (and hiding it indefinitely if the second source hangs). Check the unified errorMessage before showSpinner so any source error replaces the whole view immediately. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TokensCollectiblesInline.test.tsx | 15 ++++++++++++ .../components/TokensCollectiblesInline.tsx | 24 +++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index 55f833a25..740ce7e37 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -156,6 +156,21 @@ describe("TokensCollectiblesInline", () => { expect(screen.getByText("Error loading collectibles")).toBeTruthy(); }); + it("prioritizes an error over the spinner while the other source is still loading", () => { + setupState({ + tokensError: "token-failed", + noBalances: true, + collectiblesLoading: true, + visibleCollectibles: [], + }); + + renderComponent(); + + expect(screen.queryByTestId("tokens-collectibles-inline-spinner")).toBeNull(); + expect(screen.getByTestId("tokens-collectibles-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading balances")).toBeTruthy(); + }); + it("prefers the token error over the collectibles error", () => { setupState({ tokensError: "token-failed", diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index 97db8372a..f792a1977 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -114,6 +114,20 @@ export const TokensCollectiblesInline: React.FC< )); const renderContent = () => { + // An error from either source replaces the whole view, and takes + // precedence over the spinner: if one source fails while the other is + // still loading, we surface the error immediately rather than masking it + // behind a spinner that could otherwise hang indefinitely. + if (errorMessage) { + return ( + + + {errorMessage} + + + ); + } + if (showSpinner) { return ( @@ -126,16 +140,6 @@ export const TokensCollectiblesInline: React.FC< ); } - if (errorMessage) { - return ( - - - {errorMessage} - - - ); - } - return ( <> {hasTokens && ( From f2e670548189b1a65ce1d0a7346cccc1fcb1458d Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Thu, 11 Jun 2026 17:14:40 -0700 Subject: [PATCH 4/6] fix(send): suppress stale collectibles error during retry Per Codex review: the collectibles store keeps the previous error set while a retry is in flight (it only flips isLoading), so the new error-before-spinner ordering surfaced a stale collectibles error during retries. Gate the collectibles error on \!collectiblesLoading, mirroring useBalancesList which already suppresses tokensError while loading. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TokensCollectiblesInline.test.tsx | 19 +++++++++++++++++++ .../components/TokensCollectiblesInline.tsx | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index 740ce7e37..d619f640d 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -171,6 +171,25 @@ describe("TokensCollectiblesInline", () => { expect(screen.getByText("Error loading balances")).toBeTruthy(); }); + it("suppresses a stale collectibles error while a retry is loading", () => { + // The collectibles store keeps the old error set while re-fetching, so a + // retry-in-flight (loading + stale error) should show the spinner, not the + // stale error. + setupState({ + noBalances: true, + collectiblesLoading: true, + collectiblesError: "stale-failure", + visibleCollectibles: [], + }); + + renderComponent(); + + expect(screen.queryByTestId("tokens-collectibles-inline-error")).toBeNull(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + }); + it("prefers the token error over the collectibles error", () => { setupState({ tokensError: "token-failed", diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index f792a1977..51dd67516 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -70,10 +70,15 @@ export const TokensCollectiblesInline: React.FC< // Single error field: a token error takes precedence over a collectibles // error, and any error replaces the whole view rather than rendering the // section that succeeded. + // + // The collectibles store keeps the previous error set while a retry is in + // flight (it only flips isLoading), so we gate on !collectiblesLoading to + // avoid surfacing a stale error during a retry. useBalancesList already + // suppresses tokensError while loading, so tokens need no such guard. let errorMessage: string | null = null; if (tokensError) { errorMessage = t("balancesList.error"); - } else if (collectiblesError) { + } else if (collectiblesError && !collectiblesLoading) { errorMessage = t("collectiblesGrid.error"); } From f4e8dd250df6c0fc0ad82167a227cc44900be722 Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Thu, 11 Jun 2026 18:04:09 -0700 Subject: [PATCH 5/6] fix(send): show spinner while collectibles reload to avoid stale data Per Codex review: selectNetwork doesn't clear `collections`, so during a post-network-change refetch `visibleCollectibles` can still hold NFTs from the previous network. Gating the spinner on \!hasCollectibles rendered those stale, selectable collectibles. Restore the original behavior of showing the spinner whenever collectibles are loading. Tokens keep their noBalances gate to match BalancesList and avoid blanking a funded list on refresh. Also expand test coverage: funded-list-not-blanked-on-refresh, both sections rendered together, and assert the Tokens header in the collectibles-hidden case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TokensCollectiblesInline.test.tsx | 80 +++++++++++++++++++ .../components/TokensCollectiblesInline.tsx | 18 +++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index d619f640d..b0537ae20 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -145,6 +145,24 @@ describe("TokensCollectiblesInline", () => { ).toBeTruthy(); }); + it("does not blank the funded token list while tokens refresh in the background", () => { + // tokensLoading is true but balances are present (noBalances false), so the + // spinner must NOT show — matching BalancesList's own gate, the funded list + // stays visible during a background refresh. + setupState({ + tokensLoading: true, + noBalances: false, + visibleCollectibles: [], + }); + + renderComponent(); + + expect( + screen.queryByTestId("tokens-collectibles-inline-spinner"), + ).toBeNull(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + }); + it("shows the collectibles error when only collectibles fail", () => { setupState({ collectiblesError: "failed", noBalances: true }); @@ -171,6 +189,37 @@ describe("TokensCollectiblesInline", () => { expect(screen.getByText("Error loading balances")).toBeTruthy(); }); + it("shows the spinner (hiding potentially stale collectibles) while collectibles reload", () => { + // The store keeps `collections` from the previous network during a + // refetch, so a loading state with collectibles present must show the + // spinner rather than rendering the (possibly stale) collectibles. + setupState({ + noBalances: true, + collectiblesLoading: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Stale Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "1", + image: "https://example.com/stale.png", + name: "Stale #1", + }, + ], + }, + ], + }); + + renderComponent(); + + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + expect(screen.queryByText("Stale #1")).toBeNull(); + }); + it("suppresses a stale collectibles error while a retry is loading", () => { // The collectibles store keeps the old error set while re-fetching, so a // retry-in-flight (loading + stale error) should show the spinner, not the @@ -217,6 +266,7 @@ describe("TokensCollectiblesInline", () => { renderComponent(); + expect(screen.getByText("Tokens")).toBeTruthy(); expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); expect(screen.queryByText("Collectibles")).toBeNull(); expect( @@ -224,6 +274,36 @@ describe("TokensCollectiblesInline", () => { ).toBeNull(); }); + it("renders both the tokens and collectibles sections when both are present", () => { + setupState({ + noBalances: false, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByText("Collectible #42")).toBeTruthy(); + expect( + screen.queryByText("No tokens or collectibles to send"), + ).toBeNull(); + }); + it("hides the tokens section when there are no balances", () => { setupState({ noBalances: true, diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index 51dd67516..1719036ec 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -60,12 +60,18 @@ export const TokensCollectiblesInline: React.FC< const hasTokens = !noBalances; const hasCollectibles = visibleCollectibles.length > 0; - // Single spinner: keep it up until both sources finish their initial load. - // Gating on the "no data yet" condition (rather than raw isLoading) avoids - // the spinner flashing over already-rendered content on background refreshes. - const showSpinner = - (tokensLoading && noBalances) || - (collectiblesLoading && !hasCollectibles); + // Single spinner: keep it up until both sources finish loading. + // + // Tokens gate on "no data yet" (noBalances), matching BalancesList's own + // spinner condition so an already-funded list isn't blanked on refresh. + // + // Collectibles gate on raw isLoading. The store doesn't clear `collections` + // when the network changes (selectNetwork only swaps the network and the + // refetch keeps the old data until it resolves), so `visibleCollectibles` + // can still hold NFTs from the previous network mid-fetch. Gating on + // !hasCollectibles would render those stale, selectable collectibles; the + // spinner hides them until the refetch completes (the original behavior). + const showSpinner = (tokensLoading && noBalances) || collectiblesLoading; // Single error field: a token error takes precedence over a collectibles // error, and any error replaces the whole view rather than rendering the From c1c6e7eb293b1d4377ec52dadfb0a65d730f4fc4 Mon Sep 17 00:00:00 2001 From: Jake Urban Date: Sat, 20 Jun 2026 08:12:05 -0700 Subject: [PATCH 6/6] fix(send): keep loaded collectibles visible during background reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the collectibles spinner gate symmetric with tokens (collectiblesLoading && \!hasCollectibles) so a background refetch — which flips isLoading without clearing existing data — no longer blanks the whole page back to a spinner once collectibles have loaded. The network/account can't change while the send flow is mounted, so the on-screen data is always current and the previous stale-cross-network guard is unnecessary. Also extract renderSectionHeader and renderInlineError helpers to remove the duplicated section-header and per-section error JSX. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TokensCollectiblesInline.test.tsx | 86 ++++++++-- .../components/TokensCollectiblesInline.tsx | 156 ++++++++++-------- 2 files changed, 155 insertions(+), 87 deletions(-) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index b0537ae20..e03a7be17 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -163,18 +163,21 @@ describe("TokensCollectiblesInline", () => { expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); }); - it("shows the collectibles error when only collectibles fail", () => { + it("shows the collectibles error beneath its header when only collectibles fail", () => { setupState({ collectiblesError: "failed", noBalances: true }); renderComponent(); + expect(screen.getByText("Collectibles")).toBeTruthy(); expect( - screen.getByTestId("tokens-collectibles-inline-error"), + screen.getByTestId("collectibles-inline-error"), ).toBeTruthy(); expect(screen.getByText("Error loading collectibles")).toBeTruthy(); }); - it("prioritizes an error over the spinner while the other source is still loading", () => { + it("keeps the spinner up while one source loads even if the other has errored", () => { + // We wait for both sources before rendering content, so a settled token + // error stays hidden behind the spinner until collectibles finish loading. setupState({ tokensError: "token-failed", noBalances: true, @@ -184,28 +187,72 @@ describe("TokensCollectiblesInline", () => { renderComponent(); - expect(screen.queryByTestId("tokens-collectibles-inline-spinner")).toBeNull(); - expect(screen.getByTestId("tokens-collectibles-inline-error")).toBeTruthy(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + expect(screen.queryByTestId("tokens-inline-error")).toBeNull(); + expect(screen.queryByText("Error loading balances")).toBeNull(); + }); + + it("renders the tokens list alongside the collectibles error when only collectibles fail", () => { + setupState({ noBalances: false, collectiblesError: "failed" }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading collectibles")).toBeTruthy(); + }); + + it("renders the collectibles alongside the tokens error when only tokens fail", () => { + setupState({ + tokensError: "token-failed", + noBalances: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("tokens-inline-error")).toBeTruthy(); expect(screen.getByText("Error loading balances")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByText("Collectible #42")).toBeTruthy(); }); - it("shows the spinner (hiding potentially stale collectibles) while collectibles reload", () => { - // The store keeps `collections` from the previous network during a - // refetch, so a loading state with collectibles present must show the - // spinner rather than rendering the (possibly stale) collectibles. + it("keeps the loaded collectibles visible while they reload in the background", () => { + // Once collectibles have loaded, a background refetch (which flips + // isLoading without clearing the data) must not blank the page back to a + // spinner. The network/account can't change while the send flow is + // mounted, so the loaded data is always current. setupState({ noBalances: true, collectiblesLoading: true, visibleCollectibles: [ { collectionAddress: "CABC", - collectionName: "Stale Collection", + collectionName: "Cool Collection", items: [ { collectionAddress: "CABC", tokenId: "1", - image: "https://example.com/stale.png", - name: "Stale #1", + image: "https://example.com/item.png", + name: "Collectible #1", }, ], }, @@ -215,9 +262,9 @@ describe("TokensCollectiblesInline", () => { renderComponent(); expect( - screen.getByTestId("tokens-collectibles-inline-spinner"), - ).toBeTruthy(); - expect(screen.queryByText("Stale #1")).toBeNull(); + screen.queryByTestId("tokens-collectibles-inline-spinner"), + ).toBeNull(); + expect(screen.getByText("Collectible #1")).toBeTruthy(); }); it("suppresses a stale collectibles error while a retry is loading", () => { @@ -233,22 +280,25 @@ describe("TokensCollectiblesInline", () => { renderComponent(); - expect(screen.queryByTestId("tokens-collectibles-inline-error")).toBeNull(); + expect(screen.queryByTestId("collectibles-inline-error")).toBeNull(); expect( screen.getByTestId("tokens-collectibles-inline-spinner"), ).toBeTruthy(); }); - it("prefers the token error over the collectibles error", () => { + it("shows both section errors when both sources fail", () => { setupState({ tokensError: "token-failed", + noBalances: true, collectiblesError: "collectibles-failed", }); renderComponent(); + expect(screen.getByTestId("tokens-inline-error")).toBeTruthy(); expect(screen.getByText("Error loading balances")).toBeTruthy(); - expect(screen.queryByText("Error loading collectibles")).toBeNull(); + expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading collectibles")).toBeTruthy(); }); it("shows the combined empty fallback when there are no tokens or collectibles", () => { diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index 1719036ec..98b7c66e5 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -60,33 +60,61 @@ export const TokensCollectiblesInline: React.FC< const hasTokens = !noBalances; const hasCollectibles = visibleCollectibles.length > 0; - // Single spinner: keep it up until both sources finish loading. + // Single spinner: keep it up until both sources have data for the first time. // - // Tokens gate on "no data yet" (noBalances), matching BalancesList's own - // spinner condition so an already-funded list isn't blanked on refresh. + // Both sources gate on "no data yet" — tokens on noBalances (matching + // BalancesList's own spinner condition) and collectibles on !hasCollectibles. + // This waits for BOTH sources before any content renders, but once a section + // has data a later background refetch (which flips isLoading without clearing + // the existing data) no longer blanks the whole page back to a spinner. // - // Collectibles gate on raw isLoading. The store doesn't clear `collections` - // when the network changes (selectNetwork only swaps the network and the - // refetch keeps the old data until it resolves), so `visibleCollectibles` - // can still hold NFTs from the previous network mid-fetch. Gating on - // !hasCollectibles would render those stale, selectable collectibles; the - // spinner hides them until the refetch completes (the original behavior). - const showSpinner = (tokensLoading && noBalances) || collectiblesLoading; - - // Single error field: a token error takes precedence over a collectibles - // error, and any error replaces the whole view rather than rendering the - // section that succeeded. + // The collectibles store keeping stale data/error mid-refetch (it only flips + // isLoading) is not a concern here: the network and active account cannot + // change while the send flow is mounted, so the data on screen always belongs + // to the current account/network. // - // The collectibles store keeps the previous error set while a retry is in - // flight (it only flips isLoading), so we gate on !collectiblesLoading to - // avoid surfacing a stale error during a retry. useBalancesList already - // suppresses tokensError while loading, so tokens need no such guard. - let errorMessage: string | null = null; - if (tokensError) { - errorMessage = t("balancesList.error"); - } else if (collectiblesError && !collectiblesLoading) { - errorMessage = t("collectiblesGrid.error"); - } + // Because the spinner covers the initial load of both sources, a per-section + // error only surfaces once loading has settled, so the collectibles store's + // stale-error-during-retry is masked and the per-section error checks below + // need no extra !collectiblesLoading guard. + const showSpinner = + (tokensLoading && noBalances) || (collectiblesLoading && !hasCollectibles); + + // Each source renders its own section independently: a failed source shows + // its (curated) error beneath its section header, while the other source + // still renders its content. A section is "shown" when it either has data or + // has errored. + const tokensSectionShown = Boolean(tokensError) || hasTokens; + const collectiblesSectionShown = Boolean(collectiblesError) || hasCollectibles; + + // The combined empty fallback only applies when both sources succeeded with + // no data; an error in either section takes its place instead. + const showEmpty = !tokensSectionShown && !collectiblesSectionShown; + + const renderSectionHeader = ( + icon: React.ReactNode, + title: string, + withTopMargin = false, + ) => ( + + {icon} + + {title} + + + ); + + const renderInlineError = (testID: string, message: string) => ( + + + {message} + + + ); const renderCollectibles = () => visibleCollectibles.map((collection) => ( @@ -125,20 +153,8 @@ export const TokensCollectiblesInline: React.FC< )); const renderContent = () => { - // An error from either source replaces the whole view, and takes - // precedence over the spinner: if one source fails while the other is - // still loading, we surface the error immediately rather than masking it - // behind a spinner that could otherwise hang indefinitely. - if (errorMessage) { - return ( - - - {errorMessage} - - - ); - } - + // Wait for both sources before rendering any content, so neither a + // section's data nor its error appears until loading has fully settled. if (showSpinner) { return ( @@ -153,45 +169,47 @@ export const TokensCollectiblesInline: React.FC< return ( <> - {hasTokens && ( + {tokensSectionShown && ( <> - - - - {t("balancesList.title")} - - - - + {renderSectionHeader( + , + t("balancesList.title"), + )} + + {tokensError ? ( + renderInlineError("tokens-inline-error", t("balancesList.error")) + ) : ( + + )} )} - {hasCollectibles && ( + {collectiblesSectionShown && ( <> - - - - {t("collectiblesGrid.title")} - - - - {renderCollectibles()} + {renderSectionHeader( + , + t("collectiblesGrid.title"), + tokensSectionShown, + )} + + {collectiblesError + ? renderInlineError( + "collectibles-inline-error", + t("collectiblesGrid.error"), + ) + : renderCollectibles()} )} - {!hasTokens && !hasCollectibles && ( + {showEmpty && (