Skip to content
Merged
18 changes: 11 additions & 7 deletions src/components/Kyc/SumsubKycWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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<HTMLDivElement | null>(null)
const sdkInstanceRef = useRef<SnsWebSdkInstance | null>(null)
const { setIsSupportModalOpen } = useModalsContext()

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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'
}
Expand All @@ -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)
Expand Down Expand Up @@ -345,7 +349,7 @@ export const SumsubKycWrapper = ({
<Loading className="h-8 w-8" />
</div>
<div
ref={sdkContainerRef}
ref={setSdkContainer}
className="relative h-full w-full overflow-auto [&>iframe]:!min-h-full"
/>
</div>
Expand Down
109 changes: 109 additions & 0 deletions src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Transition>/<Dialog>, 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 <div data-testid="modal">{children}</div>
},
}))

jest.mock('@/components/Global/ActionModal', () => ({ __esModule: true, default: () => null }))
jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () => <div>loading</div> }))
jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }) }))

const launch = jest.fn()

function installSdk() {
const builder: Record<string, unknown> = {}
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(<SumsubKycWrapper visible={false} {...props} />)
expect(launch).not.toHaveBeenCalled()

rerender(<SumsubKycWrapper visible {...props} />)

// 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(
<SumsubKycWrapper
visible={false}
accessToken="tok_abc"
onClose={jest.fn()}
onComplete={jest.fn()}
onRefreshToken={jest.fn().mockResolvedValue('tok_abc')}
/>
)
await new Promise((r) => setTimeout(r, 0))
expect(launch).not.toHaveBeenCalled()
})

it('does not launch without an access token', async () => {
render(
<SumsubKycWrapper
visible
accessToken={null}
onClose={jest.fn()}
onComplete={jest.fn()}
onRefreshToken={jest.fn().mockResolvedValue('tok_abc')}
/>
)
await new Promise((r) => setTimeout(r, 0))
expect(launch).not.toHaveBeenCalled()
})
})
Loading