From ed5aac0c4a0713e5bf2b0f2a82b9258abdcdb986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 23 Jun 2026 16:20:59 +0200 Subject: [PATCH] fix(banking): stop Wise appearing multiple times in the connect list (#589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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). --- public/images/banks/logos/wise.png | Bin 6973 -> 0 bytes .../connect-account-dialog.test.tsx | 66 ++++++++++++---- .../open-banking/connect-account-dialog.tsx | 73 ++++++++++-------- .../open-banking/connect-account-inline.tsx | 4 +- resources/js/hooks/use-connect-flow.ts | 14 +++- resources/js/lib/connect-providers.tsx | 2 +- 6 files changed, 104 insertions(+), 55 deletions(-) delete mode 100644 public/images/banks/logos/wise.png diff --git a/public/images/banks/logos/wise.png b/public/images/banks/logos/wise.png deleted file mode 100644 index 4df8fad777499b3c7964638f1332c96f17af1197..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6973 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 ZPRYDt^`W|m_~+M^iIIh2#bwtA{{{Xk [ - { - 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); + }); }); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index c7d52ecc..b6b8cea5 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -149,41 +149,46 @@ export function ConnectAccountDialog({ />
- {filteredInstitutions.map((institution) => { - const isConnected = connectedBankNames.has( - institution.name, - ); + {filteredInstitutions.map( + (institution, index) => { + const isConnected = + connectedBankNames.has( + institution.name, + ); - return ( - - ); - })} + return ( + + ); + }, + )} {filteredInstitutions.length === 0 && (

{__('No banks found.')} diff --git a/resources/js/components/open-banking/connect-account-inline.tsx b/resources/js/components/open-banking/connect-account-inline.tsx index 257798d1..9dc7f636 100644 --- a/resources/js/components/open-banking/connect-account-inline.tsx +++ b/resources/js/components/open-banking/connect-account-inline.tsx @@ -131,9 +131,9 @@ export function ConnectAccountInline({ />

- {filteredInstitutions.map((institution) => ( + {filteredInstitutions.map((institution, index) => (