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..317380af89 --- /dev/null +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -0,0 +1,109 @@ +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, + // 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(() => { + setMounted(visible) + }, [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() + }) + + 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 + // `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'), + } + // 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() + 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() + }) +})