fix(banking): stop Wise appearing multiple times in the connect list (#589)

## Problem

In production, searching for a bank like **Wise** in the *Connect Bank
Account* picker showed it many times, and **the count grew every time
the search box changed** (4 → 6 → …). Only one entry — the real native
integration — should appear.

## Root cause

Two bugs stacked on top of each other:

1. **Non-unique React key.** The list rendered each institution with
`key={institution.name}`. Wise is offered both natively (our own
API-token integration) and by the Enable Banking aggregator, so two rows
shared the key `"Wise"`. On every re-render of the filtered list, React
couldn't reconcile the duplicate keys and **leaked orphaned DOM nodes**
instead of replacing them — so the entry multiplied with each keystroke
(and rendered out of alphabetical order). React even warned: *"two
children with the same key, Wise … may cause children to be
duplicated"*.
2. **No dedup against native integrations.** Even without the growth,
Wise showed twice (aggregator + native). It should only surface through
our own integration.

The bank list is built from the Enable Banking API merged with our
native providers — it does **not** read from the `banks` table, which
was a red herring (only 2 non-duplicated Wise rows there).

## Changes

- Unique key (`name-country-index`) in both the dialog and inline
pickers.
- Drop aggregator institutions we already integrate natively, so Wise
only surfaces through its own integration (general — also covers
Binance, Coinbase, etc.).
- Point the native Wise logo at the official brand mark; remove the
now-unused local asset.

## Tests

Added regression tests covering dedup and repeated filtering. Reproduced
the bug first (counts grew `[3,4,5,6,7]`), then confirmed the fix holds
it at `1`. Full JS suite green (242 tests).
This commit is contained in:
Víctor Falcón 2026-06-23 16:20:59 +02:00 committed by GitHub
parent 619ed0f1db
commit ed5aac0c4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 104 additions and 55 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -70,23 +70,27 @@ function liveBbvaConnection(): BankingConnection {
};
}
async function reachBankStep(connections: BankingConnection[]) {
type Institution = {
name: string;
country: string;
logo: string;
maximum_consent_validity: null;
};
function institution(name: string, logo = ''): Institution {
return { name, country: 'ES', logo, maximum_consent_validity: null };
}
async function reachBankStep(
connections: BankingConnection[],
institutions: Institution[] = [
institution('BBVA'),
institution('CaixaBank'),
],
) {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{
name: 'BBVA',
country: 'ES',
logo: '',
maximum_consent_validity: null,
},
{
name: 'CaixaBank',
country: 'ES',
logo: '',
maximum_consent_validity: null,
},
],
json: async () => institutions,
}) as unknown as typeof fetch;
render(
@ -104,7 +108,7 @@ async function reachBankStep(connections: BankingConnection[]) {
await waitFor(() =>
expect(
screen.getByRole('button', { name: /BBVA/ }),
screen.getByPlaceholderText('Search banks...'),
).toBeInTheDocument(),
);
}
@ -192,4 +196,34 @@ describe('ConnectAccountDialog', () => {
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled();
});
it('shows Wise once, preferring the native integration over the aggregator', async () => {
await reachBankStep(
[],
[institution('Abanca'), institution('Wise', 'aggregator-logo')],
);
expect(screen.getAllByText('Wise')).toHaveLength(1);
// The surviving entry is the native provider, with its own logo —
// not the aggregator's.
const wiseButton = screen.getByRole('button', { name: 'Wise' });
expect(wiseButton.querySelector('img')).toHaveAttribute(
'src',
'https://enablebanking.com/brands/BE/Wise/',
);
});
it('does not duplicate banks when the search is filtered repeatedly', async () => {
await reachBankStep(
[],
[institution('Abanca'), institution('Wise', 'aggregator-logo')],
);
const search = screen.getByPlaceholderText('Search banks...');
for (const query of ['wise', '', 'wise', '']) {
fireEvent.change(search, { target: { value: query } });
}
expect(screen.getAllByText('Wise')).toHaveLength(1);
});
});

View File

@ -149,41 +149,46 @@ export function ConnectAccountDialog({
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto">
{filteredInstitutions.map((institution) => {
const isConnected = connectedBankNames.has(
institution.name,
);
{filteredInstitutions.map(
(institution, index) => {
const isConnected =
connectedBankNames.has(
institution.name,
);
return (
<button
key={institution.name}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name ===
institution.name
? 'bg-accent'
: ''
}`}
onClick={() =>
setSelectedBank(institution)
}
>
<BankLogo
src={institution.logo}
className="h-6 w-6"
/>
<span>{institution.name}</span>
{isConnected && (
<Badge
variant="secondary"
className="ml-auto"
>
{__('Already connected')}
</Badge>
)}
</button>
);
})}
return (
<button
key={`${institution.name}-${institution.country}-${index}`}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name ===
institution.name
? 'bg-accent'
: ''
}`}
onClick={() =>
setSelectedBank(institution)
}
>
<BankLogo
src={institution.logo}
className="h-6 w-6"
/>
<span>{institution.name}</span>
{isConnected && (
<Badge
variant="secondary"
className="ml-auto"
>
{__(
'Already connected',
)}
</Badge>
)}
</button>
);
},
)}
{filteredInstitutions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{__('No banks found.')}

View File

@ -131,9 +131,9 @@ export function ConnectAccountInline({
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded-lg border p-1">
{filteredInstitutions.map((institution) => (
{filteredInstitutions.map((institution, index) => (
<button
key={institution.name}
key={`${institution.name}-${institution.country}-${index}`}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name === institution.name

View File

@ -152,9 +152,19 @@ export function useConnectFlow(connections: BankingConnection[]) {
),
).map((p) => p.institution);
const allInstitutions = [...extraInstitutions, ...data].sort(
(a, b) => a.name.localeCompare(b.name),
// A provider we integrate natively (e.g. Wise) must surface only
// through its own entry, never the bank-aggregator's duplicate.
const nativeNames = new Set(
CONNECT_PROVIDERS.map((p) => p.institution.name),
);
const fromProvider = (
data as EnableBankingInstitution[]
).filter((institution) => !nativeNames.has(institution.name));
const allInstitutions = [
...extraInstitutions,
...fromProvider,
].sort((a, b) => a.name.localeCompare(b.name));
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);

View File

@ -184,7 +184,7 @@ export const CONNECT_PROVIDERS: ConnectProvider[] = [
institution: {
name: 'Wise',
country: 'ALL',
logo: '/images/banks/logos/wise.png',
logo: 'https://enablebanking.com/brands/BE/Wise/',
maximum_consent_validity: null,
},
endpoint: '/open-banking/wise/connect',