commit e97557f198bc8a3fd7585537149ac7a013c26b5c
parent 031463f7dadd4687981052f116fa1d6bfb55bcd3
Author: Florian Dold <dold@taler.net>
Date: Mon, 21 Sep 2026 12:37:18 +0200
wallet-webui: clear peer choices when the last scope disappears
Keep the last scope response only while fresh data is unavailable. Treat
an empty response as authoritative so an open send or request form stops
offering a removed exchange. Preserve that empty state until a subsequent
response supplies usable scopes.
Issue: https://bugs.taler.net/n/11808
Diffstat:
3 files changed, 73 insertions(+), 22 deletions(-)
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -1985,13 +1985,17 @@ function PeerCreateRoute(props: { mode: "send" | "request" }) {
initialScopeId={
initialBalance ? scopeIdentity(initialBalance.scopeInfo) : undefined
}
- scopes={eligibleBalances.map((balance) => ({
- id: scopeIdentity(balance.scopeInfo),
- currency: balance.scopeInfo.currency,
- label: scopeLabel(balance.scopeInfo),
- available: balance.available,
- scope: balance.scopeInfo,
- }))}
+ scopes={
+ balances.data
+ ? eligibleBalances.map((balance) => ({
+ id: scopeIdentity(balance.scopeInfo),
+ currency: balance.scopeInfo.currency,
+ label: scopeLabel(balance.scopeInfo),
+ available: balance.available,
+ scope: balance.scopeInfo,
+ }))
+ : undefined
+ }
loading={balances.isLoading || !balances.data}
working={working}
error={
diff --git a/packages/wallet-webui/src/screens/PeerCreateScreen.tsx b/packages/wallet-webui/src/screens/PeerCreateScreen.tsx
@@ -147,19 +147,14 @@ export function PeerCreateScreen(props: {
}),
[props.currency],
);
- const lastScopes = useRef<PeerScopeView[]>([]);
- if (props.scopes?.length) lastScopes.current = props.scopes;
- // Balance notification revalidation can briefly omit data. Keep the exact
- // rows the user was interacting with instead of substituting a fake scope.
- const scopes = props.scopes?.length
- ? props.scopes
- : lastScopes.current.length
- ? lastScopes.current
- : [fallbackScope];
- const scopeUnavailable =
- props.scopes !== undefined &&
- props.scopes.length === 0 &&
- lastScopes.current.length === 0;
+ const lastScopes = useRef<PeerScopeView[]>();
+ // Keep the last result while data is absent. An empty result is authoritative
+ // and must clear the choices even if the user was interacting with them.
+ if (props.scopes !== undefined) lastScopes.current = props.scopes;
+ const scopes = lastScopes.current?.length
+ ? lastScopes.current
+ : [fallbackScope];
+ const scopeUnavailable = lastScopes.current?.length === 0;
const [scopeId, setScopeId] = useState(props.initialScopeId ?? scopes[0].id);
const selectedScope =
scopes.find((scope) => scope.id === scopeId) ?? scopes[0];
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -9,7 +9,10 @@ import { PaymentScreen } from "../src/screens/PaymentScreen.js";
import { PaymentTemplateScreen } from "../src/screens/PaymentTemplateScreen.js";
import { DepositScreen } from "../src/screens/DepositScreen.js";
import { depositDestinationView } from "../src/routes/deposit-model.js";
-import { PeerCreateScreen } from "../src/screens/PeerCreateScreen.js";
+import {
+ PeerCreateScreen,
+ type PeerScopeView,
+} from "../src/screens/PeerCreateScreen.js";
import { PeerReceiveScreen } from "../src/screens/PeerReceiveScreen.js";
import { PeerShareScreen } from "../src/screens/PeerShareScreen.js";
import { BankAccountsScreen } from "../src/screens/BankAccountsScreen.js";
@@ -2509,6 +2512,55 @@ test("peer creation explains how to make send and request available", async () =
await window.happyDOM.abort();
});
+for (const mode of ["send", "request"] as const) {
+ test(`peer creation clears the last removed scope in ${mode} mode`, async () => {
+ const window = installDom();
+ const { render, cleanup } = await import("@testing-library/preact");
+ const scope: PeerScopeView = {
+ id: "exchange:CHF:https://exchange.example/",
+ currency: "CHF",
+ label: "CHF via exchange.example",
+ available: "CHF:0",
+ };
+ const screen = (scopes: PeerScopeView[] | undefined) => (
+ <PeerCreateScreen
+ mode={mode}
+ currency="CHF"
+ scopes={scopes}
+ working={false}
+ onReview={() => {}}
+ onConfirm={() => {}}
+ onBack={() => {}}
+ onCancel={() => {}}
+ onWithdraw={() => {}}
+ />
+ );
+ const view = render(screen([scope]));
+ try {
+ assert(view.getByRole("option", { name: /CHF via exchange.example/ }));
+ // Missing data can be temporary; a completed empty response is not.
+ view.rerender(screen(undefined));
+ assert(view.getByRole("option", { name: /CHF via exchange.example/ }));
+ view.rerender(screen([]));
+ assert.match(
+ view.getByRole("alert").textContent ?? "",
+ new RegExp(`isn’t ready to ${mode} money yet`),
+ );
+ assert.equal(view.queryByRole("combobox"), null);
+ assert.equal(view.queryByRole("button", { name: "Review" }), null);
+ view.rerender(screen(undefined));
+ assert.equal(view.queryByRole("combobox"), null);
+ // A subsequent balance response makes the form available again.
+ view.rerender(screen([scope]));
+ assert(view.getByRole("option", { name: /CHF via exchange.example/ }));
+ assert.equal(view.queryByRole("alert"), null);
+ } finally {
+ cleanup();
+ await window.happyDOM.abort();
+ }
+ });
+}
+
test("peer request review blocks on exchange terms and offers recovery", async () => {
const window = installDom();
const { render, cleanup } = await import("@testing-library/preact");
@@ -2712,7 +2764,7 @@ test("peer creation keeps same-currency payment scopes distinct", async () => {
mode="request"
currency="CHF"
initialScopeId="exchange:CHF:https://one.example/"
- scopes={[]}
+ scopes={undefined}
working={false}
{...callbacks}
/>