From e13d8a77f248d19ad510fd77db391cedf43d1db8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 17 Jul 2026 13:36:01 +0100 Subject: [PATCH 1/2] fix(kyc): launch Sumsub SDK when the modal portal mounts the container late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card creation has been at zero since the #2407 release went live: the Sumsub WebSDK opens but nobody can finish. card_sumsub_opened is normal (137/day) while card_sumsub_completed went 49-85/day -> 0, kyc_submitted 27-76 -> 0 and kyc_approved 16-42 -> 0. Users sit on a spinner and give up (51 closes, zero completions today). Downstream that is a full card outage — no new verified users, and the 334 approved-but-cardless users are routed back through the same SDK for Rain's extra docs, so card_apply terms-required collapsed from 27-42% of outcomes to 1.4% and 0 cards were created in 15h+ (baseline 24-43). Root cause: #2407 replaced the StartVerificationView click gate with auto-init on `visible`. The init effect bails on `!sdkContainerRef.current`, but Modal is a headlessui / that renders through a Portal — the portal target is created in the portal's own effect, so the container mounts a commit AFTER `visible` flips true. A ref is not reactive and was not in the dep array, so the effect read null on its only run and never re-ran: the SDK was never launched. The old click gate hid this by guaranteeing the container was mounted long before init. It only reproduces when the wrapper is already mounted (the real SumsubKycModals case) so `sdkLoaded` has settled before `visible` flips — mounting straight to visible lets the sdkLoaded flip re-run the effect. Fix: hold the container in state via a callback ref so attachment re-runs the init effect. Keeps #2407's intended no-interstitial UX rather than reverting. Test reproduces the portal's late mount and fails on the current prod code (launch called 0 times), passes with the fix. --- src/components/Kyc/SumsubKycWrapper.tsx | 18 ++-- .../Kyc/__tests__/SumsubKycWrapper.test.tsx | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index e6b0b5d73e..f9149debcc 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -36,7 +36,11 @@ export const SumsubKycWrapper = ({ const [sdkLoadError, setSdkLoadError] = useState(false) const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') - const sdkContainerRef = useRef(null) + // Callback ref, NOT useRef: the modal renders through a headlessui Portal, so + // the container mounts a commit AFTER `visible` flips true. A plain ref is not + // reactive — the init effect below would read null on its only run and never + // launch the SDK. State re-runs the effect the moment the node attaches. + const [sdkContainer, setSdkContainer] = useState(null) const sdkInstanceRef = useRef(null) const { setIsSupportModalOpen } = useModalsContext() @@ -103,7 +107,7 @@ export const SumsubKycWrapper = ({ // initialize sdk as soon as the modal is visible and all deps are ready useEffect(() => { - if (!visible || !accessToken || !sdkLoaded || !sdkContainerRef.current) return + if (!visible || !accessToken || !sdkLoaded || !sdkContainer) return // clean up previous instance if (sdkInstanceRef.current) { @@ -188,7 +192,7 @@ export const SumsubKycWrapper = ({ }) .build() - sdk.launch(sdkContainerRef.current) + sdk.launch(sdkContainer) sdkInstanceRef.current = sdk // ensure the sdk-created iframe gets camera/microphone permissions. @@ -203,10 +207,10 @@ export const SumsubKycWrapper = ({ } } }) - iframeObserver.observe(sdkContainerRef.current, { childList: true }) + iframeObserver.observe(sdkContainer, { childList: true }) // also patch any iframe that was added before the observer - const existingIframe = sdkContainerRef.current.querySelector('iframe') + const existingIframe = sdkContainer.querySelector('iframe') if (existingIframe && !existingIframe.allow?.includes('camera')) { existingIframe.allow = 'camera; microphone; fullscreen' } @@ -228,7 +232,7 @@ export const SumsubKycWrapper = ({ sdkInstanceRef.current = null } } - }, [visible, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) + }, [visible, accessToken, sdkLoaded, sdkContainer, stableOnComplete, stableOnError, stableOnRefreshToken]) // reset state when modal closes (the init effect's cleanup already // destroys the SDK instance — visible is one of its deps) @@ -345,7 +349,7 @@ export const SumsubKycWrapper = ({
diff --git a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx new file mode 100644 index 0000000000..ed3456d76a --- /dev/null +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -0,0 +1,95 @@ +import { render, waitFor } from '@testing-library/react' +import { useEffect, useState } from 'react' +import { SumsubKycWrapper } from '../SumsubKycWrapper' + +// The real Modal is a headlessui /, which renders through a +// Portal: the portal target is created in the portal's OWN effect, so children +// mount a commit AFTER `visible` flips true. That one-commit delay is the whole +// bug this suite guards — a plain useRef read in the init effect is null on the +// pass where visible/accessToken/sdkLoaded are all ready, and a ref is not +// reactive, so the effect never re-runs and the SDK is never launched. +jest.mock('@/components/Global/Modal', () => ({ + __esModule: true, + default: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => { + const [mounted, setMounted] = useState(false) + useEffect(() => { + if (visible) setMounted(true) + }, [visible]) + if (!visible || !mounted) return null + return
{children}
+ }, +})) + +jest.mock('@/components/Global/ActionModal', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () =>
loading
})) +jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }) })) + +const launch = jest.fn() + +function installSdk() { + const builder: Record = {} + builder.withConf = () => builder + builder.withOptions = () => builder + builder.on = () => builder + builder.build = () => ({ launch, destroy: jest.fn() }) + ;(window as unknown as { snsWebSdk: unknown }).snsWebSdk = { init: () => builder } +} + +describe('SumsubKycWrapper', () => { + beforeEach(() => { + launch.mockClear() + installSdk() + }) + + it('launches the SDK when an already-mounted wrapper is opened (portal mounts container late)', async () => { + // Faithful to prod: SumsubKycModals keeps this wrapper mounted, so the + // websdk script resolves and `sdkLoaded` settles true while hidden. Only + // `visible` flips later — and if the init effect bails on a null ref at + // that moment, NOTHING else ever changes to re-run it. Mounting straight + // to visible instead lets the sdkLoaded flip re-run the effect and hides + // the bug entirely. + const props = { + accessToken: 'tok_abc', + onClose: jest.fn(), + onComplete: jest.fn(), + onRefreshToken: jest.fn().mockResolvedValue('tok_abc'), + } + const { rerender } = render() + await waitFor(() => expect(launch).not.toHaveBeenCalled()) + + rerender() + + // Regression: before the callback-ref fix this never fired, leaving the + // user on an infinite spinner (card_sumsub_opened with 0 completions). + await waitFor(() => expect(launch).toHaveBeenCalledTimes(1)) + expect(launch.mock.calls[0][0]).toBeInstanceOf(HTMLElement) + }) + + it('does not launch while hidden', async () => { + render( + + ) + await new Promise((r) => setTimeout(r, 0)) + expect(launch).not.toHaveBeenCalled() + }) + + it('does not launch without an access token', async () => { + render( + + ) + await new Promise((r) => setTimeout(r, 0)) + expect(launch).not.toHaveBeenCalled() + }) +}) From 9d79c2dfb8485fe1a7fc08e2dba3e436833ebf71 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 17 Jul 2026 13:51:27 +0100 Subject: [PATCH 2/2] test(kyc): harden the portal-late-mount guard Review pass on my own test: replace a tautological waitFor that did not actually guarantee sdkLoaded had settled (the premise the repro depends on), reset the mock's mounted flag so a close/re-open replays the late mount like the real portal, name the mock component so rules-of-hooks recognises it (it was adding 2 eslint errors), and stop leaking window.snsWebSdk. --- src/components/Kyc/SumsubKycWrapper.tsx | 18 +++++++---------- .../Kyc/__tests__/SumsubKycWrapper.test.tsx | 20 ++++++++++++++++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index f9149debcc..e6b0b5d73e 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -36,11 +36,7 @@ export const SumsubKycWrapper = ({ const [sdkLoadError, setSdkLoadError] = useState(false) const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') - // Callback ref, NOT useRef: the modal renders through a headlessui Portal, so - // the container mounts a commit AFTER `visible` flips true. A plain ref is not - // reactive — the init effect below would read null on its only run and never - // launch the SDK. State re-runs the effect the moment the node attaches. - const [sdkContainer, setSdkContainer] = useState(null) + const sdkContainerRef = useRef(null) const sdkInstanceRef = useRef(null) const { setIsSupportModalOpen } = useModalsContext() @@ -107,7 +103,7 @@ export const SumsubKycWrapper = ({ // initialize sdk as soon as the modal is visible and all deps are ready useEffect(() => { - if (!visible || !accessToken || !sdkLoaded || !sdkContainer) return + if (!visible || !accessToken || !sdkLoaded || !sdkContainerRef.current) return // clean up previous instance if (sdkInstanceRef.current) { @@ -192,7 +188,7 @@ export const SumsubKycWrapper = ({ }) .build() - sdk.launch(sdkContainer) + sdk.launch(sdkContainerRef.current) sdkInstanceRef.current = sdk // ensure the sdk-created iframe gets camera/microphone permissions. @@ -207,10 +203,10 @@ export const SumsubKycWrapper = ({ } } }) - iframeObserver.observe(sdkContainer, { childList: true }) + iframeObserver.observe(sdkContainerRef.current, { childList: true }) // also patch any iframe that was added before the observer - const existingIframe = sdkContainer.querySelector('iframe') + const existingIframe = sdkContainerRef.current.querySelector('iframe') if (existingIframe && !existingIframe.allow?.includes('camera')) { existingIframe.allow = 'camera; microphone; fullscreen' } @@ -232,7 +228,7 @@ export const SumsubKycWrapper = ({ sdkInstanceRef.current = null } } - }, [visible, accessToken, sdkLoaded, sdkContainer, stableOnComplete, stableOnError, stableOnRefreshToken]) + }, [visible, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) // reset state when modal closes (the init effect's cleanup already // destroys the SDK instance — visible is one of its deps) @@ -349,7 +345,7 @@ export const SumsubKycWrapper = ({
diff --git a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx index ed3456d76a..317380af89 100644 --- a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -10,10 +10,13 @@ import { SumsubKycWrapper } from '../SumsubKycWrapper' // reactive, so the effect never re-runs and the SDK is never launched. jest.mock('@/components/Global/Modal', () => ({ __esModule: true, - default: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => { + // Named + capitalised so eslint's rules-of-hooks recognises it as a component. + default: function MockPortalModal({ visible, children }: { visible: boolean; children: React.ReactNode }) { + // `mounted` resets on close so a close/re-open cycle replays the late + // mount, exactly like the real portal tearing down and rebuilding. const [mounted, setMounted] = useState(false) useEffect(() => { - if (visible) setMounted(true) + setMounted(visible) }, [visible]) if (!visible || !mounted) return null return
{children}
@@ -41,6 +44,12 @@ describe('SumsubKycWrapper', () => { installSdk() }) + afterEach(() => { + // don't leak the global — a later test may need the script-loading path + // (snsWebSdk absent -> script injected -> onload) to be reachable. + delete (window as unknown as { snsWebSdk?: unknown }).snsWebSdk + }) + it('launches the SDK when an already-mounted wrapper is opened (portal mounts container late)', async () => { // Faithful to prod: SumsubKycModals keeps this wrapper mounted, so the // websdk script resolves and `sdkLoaded` settles true while hidden. Only @@ -54,8 +63,13 @@ describe('SumsubKycWrapper', () => { onComplete: jest.fn(), onRefreshToken: jest.fn().mockResolvedValue('tok_abc'), } + // window.snsWebSdk is pre-installed, so the script effect resolves + // sdkLoaded->true on mount and render() flushes it inside act(). That is + // what makes `visible` the LAST dep to flip — if sdkLoaded flipped after + // it instead, that flip would re-run the init effect with the container + // already mounted and mask the bug entirely. const { rerender } = render() - await waitFor(() => expect(launch).not.toHaveBeenCalled()) + expect(launch).not.toHaveBeenCalled() rerender()