From 20dc9db1bfa9459e2532f3a3d16ecb3c6167e372 Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Fri, 24 Jul 2026 18:07:07 -0300 Subject: [PATCH] feat: add inline hCaptcha preloading --- Example.App.js | 448 +++++++++++++++--- Hcaptcha.d.ts | 34 +- Hcaptcha.js | 141 +++++- README.md | 44 +- __tests__/Hcaptcha.test.js | 125 +++++ .../ConfirmHcaptcha.test.js.snap | 7 +- __tests__/__snapshots__/Hcaptcha.test.js.snap | 7 +- __tests__/buildVerifyData.test.js | 2 + __tests__/types/legacy-consumer.tsx | 34 ++ __tests__/types/preload-consumer.tsx | 37 ++ __tests__/types/tsconfig.json | 16 + index.d.ts | 17 +- index.js | 1 + package-lock.json | 18 + package.json | 11 +- 15 files changed, 847 insertions(+), 95 deletions(-) create mode 100644 __tests__/types/legacy-consumer.tsx create mode 100644 __tests__/types/preload-consumer.tsx create mode 100644 __tests__/types/tsconfig.json diff --git a/Example.App.js b/Example.App.js index bda46e0..ec96df1 100644 --- a/Example.App.js +++ b/Example.App.js @@ -1,88 +1,408 @@ -import React, { useState, useRef } from 'react'; -import { Text, View, StyleSheet, TouchableOpacity } from 'react-native'; -import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha'; -// import ConfirmHcaptcha, { initJourneyTracking } from '@hcaptcha/react-native-hcaptcha'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Pressable, + LogBox, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import ConfirmHcaptcha, { Hcaptcha } from '@hcaptcha/react-native-hcaptcha'; -// demo sitekey -const siteKey = '00000000-0000-0000-0000-000000000000'; -const baseUrl = 'https://hcaptcha.com'; +LogBox.ignoreLogs([ + "Deep imports from the 'react-native' package are deprecated", + 'SafeAreaView has been deprecated', +]); -// Uncomment to enable automatic User Journeys collection for this example app. -// initJourneyTracking(); +const PASS_SITE_KEY = '10000000-ffff-ffff-ffff-000000000001'; +const CHALLENGE_SITE_KEY = '00000000-0000-0000-0000-000000000000'; +const BASE_URL = 'https://hcaptcha.com'; -const App = () => { - const [code, setCode] = useState(null); - const captchaForm = useRef(null); - - const onMessage = event => { - if (event && event.nativeEvent.data) { - if (event.nativeEvent.data === 'open') { - console.log('Visual challenge opened'); - } else if (event.success) { - setCode(event.nativeEvent.data); - captchaForm.current.hide(); - event.markUsed(); - console.log('Verified code from hCaptcha', event.nativeEvent.data); - } else if (event.nativeEvent.data === 'challenge-expired') { - event.reset(); - console.log('Visual challenge expired, reset...', event.nativeEvent.data); - } else /* other errors */ { - setCode(event.nativeEvent.data); - captchaForm.current.hide(); - console.log('Verification failed', event.nativeEvent.data); - } +const now = () => ( + global.performance && typeof global.performance.now === 'function' + ? global.performance.now() + : Date.now() +); + +const formatMs = value => `${Math.round(value)} ms`; + +const PreloadMatrix = () => { + const inlineRef = useRef(null); + const legacyRef = useRef(null); + const inlineMountStartedAt = useRef(now()); + const inlineExecuteStartedAt = useRef(null); + const legacyExecuteStartedAt = useRef(null); + const executeAfterMount = useRef(false); + + const [inlineKey, setInlineKey] = useState(0); + const [inlineSiteKey, setInlineSiteKey] = useState(PASS_SITE_KEY); + const [inlineStatus, setInlineStatus] = useState('loading'); + const [logs, setLogs] = useState(['App mounted; api.js preload started']); + + const appendLog = useCallback(message => { + const timestamp = new Date().toISOString().slice(11, 23); + setLogs(current => [`${timestamp} ${message}`, ...current].slice(0, 6)); + console.log(`[hCaptcha matrix] ${message}`); + }, []); + + const remountAndExecute = useCallback((siteKey, label) => { + inlineRef.current = null; + inlineMountStartedAt.current = now(); + inlineExecuteStartedAt.current = inlineMountStartedAt.current; + executeAfterMount.current = true; + setInlineStatus('loading + execute queued'); + setInlineSiteKey(siteKey); + setInlineKey(current => current + 1); + appendLog(`${label}: remounted and requested execute immediately`); + }, [appendLog]); + + useEffect(() => { + if (!executeAfterMount.current || !inlineRef.current) { + return; + } + + executeAfterMount.current = false; + inlineRef.current.execute(); + appendLog('execute() called before onReady'); + }, [appendLog, inlineKey]); + + const onInlineReady = useCallback(() => { + const elapsed = now() - inlineMountStartedAt.current; + setInlineStatus(`ready in ${formatMs(elapsed)}`); + appendLog(`inline ready: mount → ready ${formatMs(elapsed)}`); + }, [appendLog]); + + const onInlineMessage = useCallback(event => { + const data = event?.nativeEvent?.data || 'unknown'; + const elapsed = inlineExecuteStartedAt.current == null + ? null + : now() - inlineExecuteStartedAt.current; + const timing = elapsed == null ? '' : ` after ${formatMs(elapsed)}`; + + if (data === 'open') { + setInlineStatus(`challenge open${timing}`); + appendLog(`inline open${timing}`); + return; + } + + if (event.success) { + setInlineStatus(`token received${timing}`); + appendLog(`inline token${timing}`); + event.markUsed?.(); + return; + } + + setInlineStatus(`${data}${timing}`); + appendLog(`inline ${data}${timing}`); + }, [appendLog]); + + const executeReadyWidget = useCallback(() => { + inlineExecuteStartedAt.current = now(); + setInlineStatus('executing'); + appendLog('execute() called on mounted widget'); + inlineRef.current?.execute(); + }, [appendLog]); + + const resetInline = useCallback(() => { + inlineRef.current?.reset(); + inlineExecuteStartedAt.current = null; + setInlineStatus('reset; ready'); + appendLog('reset() called'); + }, [appendLog]); + + const closeInline = useCallback(() => { + inlineRef.current?.close(); + setInlineStatus('close requested'); + appendLog('close() called'); + }, [appendLog]); + + const showLegacy = useCallback(() => { + legacyExecuteStartedAt.current = now(); + appendLog('legacy show() called'); + legacyRef.current?.show(); + }, [appendLog]); + + const onLegacyMessage = useCallback(event => { + const data = event?.nativeEvent?.data || 'unknown'; + const elapsed = legacyExecuteStartedAt.current == null + ? null + : now() - legacyExecuteStartedAt.current; + const timing = elapsed == null ? '' : ` after ${formatMs(elapsed)}`; + + appendLog(`legacy ${event.success ? 'token' : data}${timing}`); + if (event.success) { + event.markUsed?.(); + } + if (data !== 'open') { + legacyRef.current?.hide(); + } + }, [appendLog]); + + return ( + + + hCaptcha preload matrix + + Inline: {inlineStatus} + + + + + + + remountAndExecute(PASS_SITE_KEY, 'pass key')} + testID="execute-loading" + /> + remountAndExecute(CHALLENGE_SITE_KEY, 'challenge key')} + testID="open-challenge" + /> + + + + + + Newest events + {logs.map((message, index) => ( + + {message} + + ))} + + + + + + + + + ); +}; + +const LegacyColdStart = ({ onContinue, startedAt }) => { + const legacyRef = useRef(null); + const startedRef = useRef(false); + const [status, setStatus] = useState('mounting legacy widget'); + + useEffect(() => { + if (startedRef.current) { + return; + } + + startedRef.current = true; + legacyRef.current?.show(); + }, []); + + const onMessage = useCallback(event => { + const data = event?.nativeEvent?.data || 'unknown'; + const timing = ` after ${formatMs(now() - startedAt)}`; + + if (data === 'open') { + setStatus(`challenge open${timing}`); + return; + } + + if (event.success) { + setStatus(`token received${timing}`); + event.markUsed?.(); + } else { + setStatus(`${data}${timing}`); } - }; + legacyRef.current?.hide(); + }, [startedAt]); return ( - + + + Legacy cold-start result + + {status} + + + This path mounted no inline WebView and made no api.js request before the test began. + + + + - { - captchaForm.current.show(); - }}> - Click to launch - - {code && ( - - {'passcode or status: '} - - {code} - + + ); +}; + +const App = () => { + const legacyStartedAt = useRef(null); + const [mode, setMode] = useState(null); + + if (mode === 'legacy') { + return ( + setMode('preload')} + /> + ); + } + + if (mode === 'preload') { + return ; + } + + return ( + + + Choose a clean-start path + + Select legacy first to measure the old flow before any hCaptcha WebView or api.js preload exists. - )} - + + { + legacyStartedAt.current = now(); + setMode('legacy'); + }} + testID="legacy-cold-start" + /> + setMode('preload')} + testID="preload-matrix" + /> + + + ); }; +const ActionButton = ({ label, onPress, testID }) => ( + [styles.button, pressed && styles.buttonPressed]} + testID={testID} + > + {label} + +); + const styles = StyleSheet.create({ - container: { + screen: { + backgroundColor: '#f5f6fa', + flex: 1, + }, + header: { + paddingHorizontal: 16, + paddingTop: 10, + }, + modeScreen: { flex: 1, justifyContent: 'center', - backgroundColor: '#ecf0f1', - padding: 8, + padding: 24, + }, + title: { + color: '#1f2430', + fontSize: 20, + fontWeight: '700', }, - paragraph: { - margin: 24, - fontSize: 18, - fontWeight: 'bold', + status: { + color: '#3b438a', + fontSize: 14, + fontWeight: '600', + marginTop: 4, + }, + description: { + color: '#4e5565', + fontSize: 14, + lineHeight: 20, + marginBottom: 20, + marginTop: 10, + }, + controls: { + flexGrow: 0, + maxHeight: 290, + }, + controlsContent: { + padding: 12, + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + button: { + backgroundColor: '#3341c7', + borderRadius: 8, + minWidth: '47%', + paddingHorizontal: 10, + paddingVertical: 10, + }, + buttonPressed: { + opacity: 0.7, + }, + buttonText: { + color: '#ffffff', + fontSize: 13, + fontWeight: '600', textAlign: 'center', }, - codeContainer: { - alignSelf: 'center', + logTitle: { + color: '#1f2430', + fontSize: 13, + fontWeight: '700', + marginTop: 12, }, - codeText: { - color: 'darkviolet', - fontSize: 6, - fontWeight: 'bold', + logLine: { + color: '#4e5565', + fontFamily: 'Courier', + fontSize: 10, + marginTop: 3, + }, + inlinePanel: { + backgroundColor: '#ffffff', + borderColor: '#d9dce8', + borderTopWidth: StyleSheet.hairlineWidth, + flex: 1, + minHeight: 280, + overflow: 'hidden', }, }); diff --git a/Hcaptcha.d.ts b/Hcaptcha.d.ts index 8268b70..6ff62d3 100644 --- a/Hcaptcha.d.ts +++ b/Hcaptcha.d.ts @@ -6,6 +6,23 @@ export type HCaptchaVerifyParams = { rqdata?: string; phonePrefix?: string; phoneNumber?: string; + mfaEmail?: string; +}; + +export type HCaptchaHandle = { + /** + * Executes hCaptcha with optional verification parameters for this attempt. + * Calls made before readiness are queued until initialization completes. + */ + execute: (verifyParams?: HCaptchaVerifyParams) => void; + /** + * Resets the current hCaptcha widget without executing it. + */ + reset: () => void; + /** + * Closes the current challenge without unmounting the preloaded widget. + */ + close: () => void; }; export type HcaptchaProps = { @@ -13,6 +30,15 @@ export type HcaptchaProps = { * The callback function that runs after receiving a response, error, or when user cancels. */ onMessage: (event: CustomWebViewMessageEvent) => void; + /** + * Runs when hCaptcha has loaded and rendered the widget. + */ + onReady?: () => void; + /** + * Whether to execute automatically after hCaptcha is ready. + * Defaults to true. Set to false to preload the inline component. + */ + autoExecute?: boolean; /** * The size of the checkbox. */ @@ -115,10 +141,14 @@ export type HcaptchaProps = { userJourney?: boolean; } -interface CustomWebViewMessageEvent extends WebViewMessageEvent { +export interface CustomWebViewMessageEvent extends WebViewMessageEvent { success: boolean; reset: () => void; markUsed?: () => void; } -export default class Hcaptcha extends React.Component {} +export default class Hcaptcha extends React.Component { + execute: HCaptchaHandle['execute']; + reset: HCaptchaHandle['reset']; + close: HCaptchaHandle['close']; +} diff --git a/Hcaptcha.js b/Hcaptcha.js index 2936b4c..7b44186 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -1,4 +1,12 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import hCaptchaLoaderInlineScript from '@hcaptcha/loader/inline'; import WebView from 'react-native-webview'; import { ActivityIndicator, Linking, Platform, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; @@ -116,6 +124,7 @@ const buildVerifyData = ({ const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined; const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined; const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined; + const finalMfaEmail = normalizedVerifyParams.mfaEmail ?? undefined; if (finalRqdata) { data.rqdata = finalRqdata; @@ -126,6 +135,9 @@ const buildVerifyData = ({ if (finalPhoneNumber) { data.mfa_phone = finalPhoneNumber; } + if (finalMfaEmail) { + data.mfa_email = finalMfaEmail; + } if (Array.isArray(userJourney) && userJourney.length > 0) { data.userjourney = userJourney; } @@ -175,6 +187,8 @@ function buildHcaptchaLoaderConfig({ /** * * @param {*} onMessage: callback after receiving response, error, or when user cancels + * @param {function} onReady: callback when hCaptcha is ready to execute + * @param {boolean} autoExecute: execute automatically after hCaptcha is ready * @param {*} siteKey: your hCaptcha sitekey * @param {string} size: The size of the widget, can be 'invisible', 'compact' or 'normal'. 'checkbox' is kept as a legacy alias for 'normal'. Default: 'invisible' * @param {*} style: custom style @@ -200,8 +214,10 @@ function buildHcaptchaLoaderConfig({ * @param {boolean} userJourney: Enable automatic user journey injection * @param {object} verifyParams: Verification payload overrides */ -const Hcaptcha = ({ +const Hcaptcha = forwardRef(({ onMessage, + onReady, + autoExecute = true, size, siteKey, style, @@ -227,11 +243,18 @@ const Hcaptcha = ({ userJourney, verifyParams, _journeyManagedExternally, -}) => { +}, ref) => { const tokenTimeout = 120000; const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); const isLoadingRef = useRef(true); + const isReadyRef = useRef(false); + const hasExecutedRef = useRef(false); + const lastExecutionVerifyParamsRef = useRef(undefined); + const pendingExecutionRef = useRef({ + pending: false, + verifyParams: undefined, + }); const journeyEnabled = Boolean(userJourney); const hasJourneyConsumerRef = useRef(false); const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]); @@ -309,6 +332,9 @@ const Hcaptcha = ({ var reset = function() { hcaptcha.reset(hcaptchaWidgetId); }; + var closeChallenge = function() { + hcaptcha.close(hcaptchaWidgetId); + }; var onloadCallback = function() { try { console.log("challenge onload starting"); @@ -332,8 +358,8 @@ const Hcaptcha = ({ window.ReactNativeWebView.postMessage("open"); console.log("challenge opened"); }; - var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); }; - var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); }; + var onDataExpiredCallback = function() { window.ReactNativeWebView.postMessage("expired"); }; + var onChalExpiredCallback = function() { window.ReactNativeWebView.postMessage("challenge-expired"); }; var onDataErrorCallback = function(error) { console.warn("challenge error callback fired"); window.ReactNativeWebView.postMessage(error); @@ -394,19 +420,77 @@ const Hcaptcha = ({ }, [onMessage]); const webViewRef = useRef(null); - const injectVerifyData = (resetFirst = false) => { + const injectVerifyData = useCallback((resetFirst = false, executionVerifyParams) => { if (!webViewRef.current) { - return; + return false; } + const finalVerifyParams = executionVerifyParams === undefined + ? verifyParams + : { + ...(verifyParams || {}), + ...executionVerifyParams, + }; + webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({ phoneNumber, phonePrefix, rqdata, userJourney: journeyEnabled ? peekJourneyEvents() : undefined, - verifyParams, + verifyParams: finalVerifyParams, }), resetFirst)); - }; + + return true; + }, [journeyEnabled, phoneNumber, phonePrefix, rqdata, verifyParams]); + + const executeNow = useCallback((executionVerifyParams) => { + if (injectVerifyData(hasExecutedRef.current, executionVerifyParams)) { + hasExecutedRef.current = true; + lastExecutionVerifyParamsRef.current = executionVerifyParams; + } + }, [injectVerifyData]); + + const execute = useCallback((executionVerifyParams) => { + if (!isReadyRef.current) { + pendingExecutionRef.current = { + pending: true, + verifyParams: executionVerifyParams, + }; + return; + } + + executeNow(executionVerifyParams); + }, [executeNow]); + + const resetWidget = useCallback(() => { + pendingExecutionRef.current = { + pending: false, + verifyParams: undefined, + }; + hasExecutedRef.current = false; + lastExecutionVerifyParamsRef.current = undefined; + + if (isReadyRef.current && webViewRef.current) { + webViewRef.current.injectJavaScript('reset(); true;'); + } + }, []); + + const closeWidget = useCallback(() => { + pendingExecutionRef.current = { + pending: false, + verifyParams: undefined, + }; + + if (isReadyRef.current && webViewRef.current) { + webViewRef.current.injectJavaScript('closeChallenge(); true;'); + } + }, []); + + useImperativeHandle(ref, () => ({ + execute, + reset: resetWidget, + close: closeWidget, + }), [closeWidget, execute, resetWidget]); // This shows ActivityIndicator till webview loads hCaptcha images const renderLoading = () => ( @@ -417,17 +501,21 @@ const Hcaptcha = ({ ); - const reset = () => { - injectVerifyData(true); - }; + const retryVerification = useCallback(() => { + if (injectVerifyData(true, lastExecutionVerifyParamsRef.current)) { + hasExecutedRef.current = true; + } + }, [injectVerifyData]); - const retryApiLoad = () => { + const retryApiLoad = useCallback(() => { if (!webViewRef.current) { return; } + isReadyRef.current = false; + hasExecutedRef.current = false; webViewRef.current.injectJavaScript('loadApiScript(); true;'); - }; + }, []); return ( @@ -459,19 +547,34 @@ const Hcaptcha = ({ setIsLoading(false); if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) { - injectVerifyData(); + const pendingExecution = pendingExecutionRef.current; + pendingExecutionRef.current = { + pending: false, + verifyParams: undefined, + }; + isReadyRef.current = true; + + if (pendingExecution.pending) { + executeNow(pendingExecution.verifyParams); + } else if (autoExecute) { + executeNow(); + } + + if (onReady) { + onReady(); + } return; } if (e.nativeEvent.data === 'script-error') { e.reset = retryApiLoad; } else { - e.reset = reset; + e.reset = retryVerification; } e.success = true; if (e.nativeEvent.data === 'open') { } else if (e.nativeEvent.data.length > 35) { - const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); + const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset: retryVerification }), tokenTimeout); e.markUsed = () => clearTimeout(expiredTokenTimerId); if (journeyEnabled) { clearJourneyEvents(); @@ -493,7 +596,7 @@ const Hcaptcha = ({ {showLoading && isLoading && renderLoading()} ); -}; +}); const styles = StyleSheet.create({ container: { @@ -509,5 +612,7 @@ const styles = StyleSheet.create({ }, }); +Hcaptcha.displayName = 'Hcaptcha'; + export default Hcaptcha; export { buildDebugInfo, buildVerifyData, HCAPTCHA_READY_EVENT }; diff --git a/README.md b/README.md index 3e0c5eb..fd6ffe4 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,12 @@ Use `verifyParams` for request data passed to `hcaptcha.setData(...)` immediatel rqdata: enterpriseRqdata, phonePrefix: '44', phoneNumber: '+44123456789', + mfaEmail: 'user@example.com', }} /> ``` -Legacy top-level `rqdata`, `phonePrefix`, and `phoneNumber` props still work, but `verifyParams` takes precedence and should be preferred for new code. +Legacy top-level `rqdata`, `phonePrefix`, and `phoneNumber` props still work, but `verifyParams` takes precedence and should be preferred for new code. `mfaEmail` is available through `verifyParams`. ### User Journeys (Enterprise) @@ -193,6 +194,43 @@ import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha'; /> ``` +To preload hCaptcha, keep the inline component mounted with `autoExecute={false}` and execute it later through its ref. Verification parameters passed to `execute()` apply only to that attempt and take precedence over the component props. + +```js +import React, { useRef } from 'react'; +import { Button, View } from 'react-native'; +import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha'; + +export default function Example() { + const captchaRef = useRef(null); + + return ( + +