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 (
+
+
+ );
+}
+```
+
+The inline component must remain mounted between initialization and execution. The surrounding view is responsible for positioning it when a visual challenge is shown. Calling `close()` dismisses an active challenge without unmounting the preloaded widget.
+
### Handling the post-issuance expiration lifecycle
This extension is a lightweight wrapper, and does not currently attempt to manage post-verification state in the same way as the web JS API, e.g. with an on-expire callback.
@@ -347,6 +385,8 @@ For new code, prefer:
| siteKey _(required)_ | string | The hCaptcha siteKey |
| size | string | The size of the widget, can be 'invisible', 'compact' or 'normal'. `checkbox` is also accepted as a legacy alias for `normal`. Default: 'invisible' |
| onMessage | Function (see [here](https://github.com/react-native-webview/react-native-webview/blob/master/src/WebViewTypes.ts#L299)) | Required. Runs after receiving a response, error, or when user cancels. |
+| onReady _(inline component only)_ | Function | Runs when hCaptcha has loaded and rendered the widget. |
+| autoExecute _(inline component only)_ | boolean | Whether to execute automatically after hCaptcha is ready. Defaults to `true`; set to `false` to preload the component. |
| languageCode | string | Default language for hCaptcha; overrides phone defaults. A complete list of supported languages and their codes can be found [here](https://docs.hcaptcha.com/languages/) |
| showLoading | boolean | Whether to show a loading indicator while the hCaptcha web content loads |
| closableLoading | boolean | Allow user to cancel hcaptcha during loading by touch loader overlay |
@@ -354,7 +394,7 @@ For new code, prefer:
| backgroundColor | string | The background color code that will be applied to the main HTML element |
| theme | string\|object | The theme can be 'light', 'dark', 'contrast' or a custom theme object (see Enterprise docs) |
| rqdata | string | **Deprecated**: Use `rqdata` in `HCaptchaVerifyParams` instead. Will be removed in future releases. See Enterprise docs. |
-| verifyParams | object | Verification payload overrides passed to `hcaptcha.setData(...)` immediately before verification. Supports `rqdata`, `phonePrefix`, and `phoneNumber`. |
+| verifyParams | object | Verification payload overrides passed to `hcaptcha.setData(...)` immediately before verification. Supports `rqdata`, `phonePrefix`, `phoneNumber`, and `mfaEmail`. |
| userJourney | boolean | When `true`, attaches the current shared journey buffer to the verification payload as `userjourney`. It also enables automatic touch capture by default while a `userJourney` captcha instance is mounted. Use `initJourneyTracking({ touchCapture: false })` to keep User Journeys enabled without automatic touch capture. |
| sentry | boolean | Enables hCaptcha error reporting, including API loading failures. Set to `false` to disable (see Enterprise docs). |
| jsSrc | string | The url of api.js. Default: https://js.hcaptcha.com/1/api.js (Override only if using first-party hosting feature.) |
diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js
index a4c793d..23c66a5 100644
--- a/__tests__/Hcaptcha.test.js
+++ b/__tests__/Hcaptcha.test.js
@@ -226,6 +226,10 @@ describe('Hcaptcha', () => {
renderConfig['open-callback']();
expect(context.document.body.style.backgroundColor).toBe(config.backgroundColor);
expect(postMessageMock).toHaveBeenCalledWith('open');
+ renderConfig['expired-callback']();
+ expect(postMessageMock).toHaveBeenCalledWith('expired');
+ renderConfig['chalexpired-callback']();
+ expect(postMessageMock).toHaveBeenCalledWith('challenge-expired');
});
it('uses the published loader contract and forwards terminal failures after retries', async () => {
@@ -449,11 +453,13 @@ describe('Hcaptcha', () => {
it('does not emit a loading timeout after the widget becomes ready in passive flows', () => {
jest.useFakeTimers();
const onMessage = jest.fn();
+ const onReady = jest.fn();
const component = render(
);
@@ -468,9 +474,128 @@ describe('Hcaptcha', () => {
description: 'loading timeout',
},
});
+ expect(onReady).toHaveBeenCalledTimes(1);
expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();'));
});
+ it('preloads without executing and queues the latest execution parameters until ready', () => {
+ const captchaRef = React.createRef();
+ const onReady = jest.fn();
+ const component = render(
+
+ );
+
+ act(() => {
+ captchaRef.current.execute({ rqdata: 'first' });
+ captchaRef.current.execute({ rqdata: 'latest' });
+ });
+
+ expect(getLastInjectJavaScriptMock()).not.toHaveBeenCalled();
+
+ act(() => {
+ getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } });
+ });
+
+ expect(onReady).toHaveBeenCalledTimes(1);
+ expect(getLastInjectJavaScriptMock()).toHaveBeenCalledTimes(1);
+ expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"latest"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.not.stringContaining('"rqdata":"first"'));
+ });
+
+ it('executes a preloaded widget through its ref and resets before later executions', () => {
+ const captchaRef = React.createRef();
+ const component = render(
+
+ );
+
+ act(() => {
+ getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } });
+ });
+
+ expect(getLastInjectJavaScriptMock()).not.toHaveBeenCalled();
+
+ act(() => {
+ captchaRef.current.execute({ rqdata: 'first-attempt' });
+ });
+
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"rqdata":"first-attempt"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"mfa_email":"user@example.com"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.not.stringContaining('reset();'));
+
+ act(() => {
+ captchaRef.current.execute({
+ rqdata: 'second-attempt',
+ phonePrefix: '55',
+ });
+ });
+
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('reset(); setData('));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"rqdata":"second-attempt"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"mfa_phoneprefix":"55"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"mfa_phone":"+44123"'));
+
+ act(() => {
+ captchaRef.current.reset();
+ });
+
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith('reset(); true;');
+
+ act(() => {
+ captchaRef.current.execute({ rqdata: 'after-reset' });
+ });
+
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.stringContaining('"rqdata":"after-reset"'));
+ expect(getLastInjectJavaScriptMock()).toHaveBeenLastCalledWith(expect.not.stringContaining('reset(); setData('));
+ });
+
+ it('closes a preloaded challenge through its ref and cancels queued execution', () => {
+ const captchaRef = React.createRef();
+ const component = render(
+
+ );
+
+ act(() => {
+ captchaRef.current.execute({ rqdata: 'cancelled-before-ready' });
+ captchaRef.current.close();
+ getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } });
+ });
+
+ expect(getLastInjectJavaScriptMock()).not.toHaveBeenCalled();
+
+ act(() => {
+ captchaRef.current.execute({ rqdata: 'active-attempt' });
+ captchaRef.current.close();
+ });
+
+ expect(getLastInjectJavaScriptMock()).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('"rqdata":"active-attempt"')
+ );
+ expect(getLastInjectJavaScriptMock()).toHaveBeenNthCalledWith(2, 'closeChallenge(); true;');
+ });
+
it('forwards open messages, marks them as successful, and hides the loading overlay', async () => {
const onMessage = jest.fn();
setWebViewMessageData('open');
diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap
index d309654..125c4b1 100644
--- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap
+++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap
@@ -139,6 +139,9 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show()
var reset = function() {
hcaptcha.reset(hcaptchaWidgetId);
};
+ var closeChallenge = function() {
+ hcaptcha.close(hcaptchaWidgetId);
+ };
var onloadCallback = function() {
try {
console.log("challenge onload starting");
@@ -162,8 +165,8 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show()
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);
diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap
index ee656fd..946a87b 100644
--- a/__tests__/__snapshots__/Hcaptcha.test.js.snap
+++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap
@@ -73,6 +73,9 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = `
var reset = function() {
hcaptcha.reset(hcaptchaWidgetId);
};
+ var closeChallenge = function() {
+ hcaptcha.close(hcaptchaWidgetId);
+ };
var onloadCallback = function() {
try {
console.log("challenge onload starting");
@@ -96,8 +99,8 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = `
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);
diff --git a/__tests__/buildVerifyData.test.js b/__tests__/buildVerifyData.test.js
index 0ed7d7d..491e540 100644
--- a/__tests__/buildVerifyData.test.js
+++ b/__tests__/buildVerifyData.test.js
@@ -19,12 +19,14 @@ describe('buildVerifyData', () => {
phonePrefix: '11',
phoneNumber: '+111',
verifyParams: {
+ mfaEmail: 'user@example.com',
rqdata: 'preferred-rqdata',
phonePrefix: '44',
phoneNumber: '+44123',
},
})).toEqual({
rqdata: 'preferred-rqdata',
+ mfa_email: 'user@example.com',
mfa_phoneprefix: '44',
mfa_phone: '+44123',
});
diff --git a/__tests__/types/legacy-consumer.tsx b/__tests__/types/legacy-consumer.tsx
new file mode 100644
index 0000000..04d0a5a
--- /dev/null
+++ b/__tests__/types/legacy-consumer.tsx
@@ -0,0 +1,34 @@
+import React, { useRef } from 'react';
+
+import ConfirmHcaptcha, { Hcaptcha } from '../..';
+import InlineHcaptcha from '../../Hcaptcha';
+
+const onMessage = () => {};
+
+export function LegacyModalConsumer() {
+ const captchaRef = useRef(null);
+
+ return (
+
+ );
+}
+
+export function LegacyInlineConsumer() {
+ const captchaRef = useRef(null);
+
+ return (
+
+ );
+}
diff --git a/__tests__/types/preload-consumer.tsx b/__tests__/types/preload-consumer.tsx
new file mode 100644
index 0000000..8ccba0a
--- /dev/null
+++ b/__tests__/types/preload-consumer.tsx
@@ -0,0 +1,37 @@
+import React, { useRef } from 'react';
+
+import {
+ Hcaptcha,
+ type HCaptchaHandle,
+} from '../..';
+
+const onMessage = () => {};
+
+export function PreloadConsumer() {
+ const captchaRef = useRef(null);
+
+ return (
+ {
+ captchaRef.current?.execute({
+ mfaEmail: 'user@example.com',
+ rqdata: 'fresh-rqdata',
+ });
+ }}
+ siteKey="10000000-ffff-ffff-ffff-000000000001"
+ size="invisible"
+ url="https://hcaptcha.com"
+ />
+ );
+}
+
+export function usePreloadActions(ref: React.RefObject) {
+ return {
+ close: () => ref.current?.close(),
+ execute: () => ref.current?.execute(),
+ reset: () => ref.current?.reset(),
+ };
+}
diff --git a/__tests__/types/tsconfig.json b/__tests__/types/tsconfig.json
new file mode 100644
index 0000000..9facdb6
--- /dev/null
+++ b/__tests__/types/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "es2020"
+ },
+ "files": [
+ "legacy-consumer.tsx",
+ "preload-consumer.tsx"
+ ]
+}
diff --git a/index.d.ts b/index.d.ts
index ceb7d09..55d0fde 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,5 +1,8 @@
import React from 'react';
-import Hcaptcha, { HcaptchaProps } from './Hcaptcha';
+import Hcaptcha, {
+ HCaptchaHandle,
+ HcaptchaProps,
+} from './Hcaptcha';
export type JourneyRuntimeStats = {
activeConsumers: number;
@@ -18,7 +21,7 @@ export type JourneyTrackingOptions = {
onStats?: (stats: JourneyRuntimeStats) => void;
};
-type ConfirmHcaptchaProps = Omit & {
+type ConfirmHcaptchaProps = Omit & {
/**
* Indicates whether the passive mode is enabled; when true, the modal won't be shown at all
*/
@@ -59,4 +62,12 @@ export function initJourneyTracking(options?: JourneyTrackingOptions): void;
export function registerJourneyNavigationContainer(ref: unknown): void;
-export { Hcaptcha };
+export const Hcaptcha: React.ForwardRefExoticComponent<
+ HcaptchaProps & React.RefAttributes
+>;
+export type {
+ CustomWebViewMessageEvent,
+ HCaptchaHandle,
+ HCaptchaVerifyParams,
+ HcaptchaProps,
+} from './Hcaptcha';
diff --git a/index.js b/index.js
index 9ee08a5..feb6d92 100644
--- a/index.js
+++ b/index.js
@@ -228,6 +228,7 @@ ConfirmHcaptcha.propTypes = {
phoneNumber: PropTypes.string,
userJourney: PropTypes.bool,
verifyParams: PropTypes.shape({
+ mfaEmail: PropTypes.string,
phoneNumber: PropTypes.string,
phonePrefix: PropTypes.string,
rqdata: PropTypes.string,
diff --git a/package-lock.json b/package-lock.json
index e3b6927..6f270eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@react-native/babel-preset": "^0.78.0",
"@react-native/eslint-config": "^0.78.0",
"@testing-library/react-native": "^13.2.0",
+ "@types/react": "^19.2.17",
"eslint": "^8.19.0",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
@@ -3394,6 +3395,16 @@
"@types/node": "*"
}
},
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
@@ -4741,6 +4752,13 @@
"node": ">= 8"
}
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
diff --git a/package.json b/package.json
index 28d9a26..8b2de4d 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"main": "index.js",
"scripts": {
"prepare": "husky",
- "test": "jest --testPathIgnorePatterns=\\.perf-test\\.js$",
+ "test": "jest",
+ "test:types": "tsc -p __tests__/types/tsconfig.json",
"test:e2e:setup": "node __scripts__/setup-e2e-host.js",
"test:e2e:install:android": "adb install -r __e2e__/host/android/app/build/outputs/apk/debug/app-debug.apk",
"test:e2e:install:ios": "xcrun simctl install booted __e2e__/host/ios/build/Build/Products/Debug-iphonesimulator/react_native_hcaptcha_example.app",
@@ -22,9 +23,14 @@
"setupFiles": [
"/__mocks__/global.js"
],
+ "modulePathIgnorePatterns": [
+ "/__e2e__/host/"
+ ],
"testPathIgnorePatterns": [
"/node_modules/",
- "/__e2e__/"
+ "/__e2e__/",
+ "/__tests__/types/",
+ "\\.perf-test\\.js$"
]
},
"repository": {
@@ -64,6 +70,7 @@
"@react-native/babel-preset": "^0.78.0",
"@react-native/eslint-config": "^0.78.0",
"@testing-library/react-native": "^13.2.0",
+ "@types/react": "^19.2.17",
"eslint": "^8.19.0",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",