Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4ecea20
feat: load api.js through hCaptcha Loader
damian-rodriguez-imi Jul 10, 2026
21d9fe0
feat: add React Native loader diagnostics
damian-rodriguez-imi Jul 13, 2026
650acc9
chore: minimize loader diagnostics changes
damian-rodriguez-imi Jul 13, 2026
2e4f36a
chore: remove loader lifecycle bridge
damian-rodriguez-imi Jul 13, 2026
2284c55
fix: restore inline WebView handling
damian-rodriguez-imi Jul 13, 2026
5ee4969
chore: remove native loader timeout reporting
damian-rodriguez-imi Jul 13, 2026
679c216
feat: bundle hCaptcha Loader with the SDK
damian-rodriguez-imi Jul 13, 2026
6db9c6c
chore: remove loader test changes
damian-rodriguez-imi Jul 13, 2026
80a3468
chore: update hCaptcha Loader dependency
damian-rodriguez-imi Jul 14, 2026
7dbc6ea
feat: use structured hCaptcha loader config
damian-rodriguez-imi Jul 14, 2026
07733a3
refactor: finalize loader integration
damian-rodriguez-imi Jul 14, 2026
04dd7b2
refactor: preserve loading behavior
damian-rodriguez-imi Jul 14, 2026
39a2a0b
fix: use published hcaptcha loader
damian-rodriguez-imi Jul 15, 2026
edccef2
ci: pin Xcode runner image
damian-rodriguez-imi Jul 15, 2026
ce4ff65
ci: pin iOS E2E runner image
damian-rodriguez-imi Jul 15, 2026
8484367
fix: support React Native 0.86 Jest preset
damian-rodriguez-imi Jul 15, 2026
ee50790
fix: remove redundant Babel preset
damian-rodriguez-imi Jul 15, 2026
9827987
fix: update hcaptcha loader to 2.4.1
damian-rodriguez-imi Jul 15, 2026
85c942b
fix: preserve loader configuration parity
damian-rodriguez-imi Jul 15, 2026
30f26bd
chore: bump version to 4.1.0
damian-rodriguez-imi Jul 15, 2026
7ee98dc
chore: remove unrelated whitespace change
damian-rodriguez-imi Jul 15, 2026
5eb943e
chore: addressed feedback
damian-rodriguez-imi Jul 16, 2026
71e25fd
fix: update hcaptcha loader to 2.4.2
damian-rodriguez-imi Jul 16, 2026
a080c13
docs: document api loading retries
damian-rodriguez-imi Jul 17, 2026
e0eff69
fix: passive timeout
damian-rodriguez-imi Jul 17, 2026
81cbb73
fix: retry api loading after script error
damian-rodriguez-imi Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ jobs:
platform: [ android ]
pm: [ npm, yarn ]
include:
- os: macos-latest
- os: macos-15
platform: ios
pm: npm
- os: macos-latest
- os: macos-15
platform: ios
pm: yarn
steps:
Expand Down Expand Up @@ -137,7 +137,7 @@ jobs:
include:
- os: ubuntu-latest
platform: android
- os: macos-latest
- os: macos-15
platform: ios
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down
2 changes: 1 addition & 1 deletion Example.jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
preset: 'react-native',
preset: '@react-native/jest-preset',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testPathIgnorePatterns: ['/node_modules/'],
modulePathIgnorePatterns: ['<rootDir>/react-native-hcaptcha/'],
Expand Down
114 changes: 84 additions & 30 deletions Hcaptcha.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, 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';
import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion';
Expand Down Expand Up @@ -135,25 +136,42 @@ const buildVerifyData = ({
const buildVerifyInjectionScript = (payload, resetFirst = false) =>
`try { ${resetFirst ? 'reset(); ' : ''}setData(${serializeForInlineScript(payload)}); execute(); } catch (e) { window.ReactNativeWebView.postMessage((e && e.name) || 'error'); } true;`;

const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation) => {
var url = `${jsSrc || 'https://hcaptcha.com/1/api.js'}?render=explicit&onload=onloadCallback`;

let effectiveHost;
const getHcaptchaHost = (host, siteKey) => {
if (host) {
effectiveHost = encodeURIComponent(host);
return host;
} else if (siteKey) {
return `${siteKey}.react-native.hcaptcha.com`;
} else {
effectiveHost = (siteKey || 'missing-sitekey') + '.react-native.hcaptcha.com';
}

for (let [key, value] of Object.entries({ host: effectiveHost, hl, custom: typeof theme === 'object', sentry, endpoint, assethost, imghost, reportapi, orientation })) {
if (value) {
url += `&${key}=${encodeURIComponent(value)}`;
}
return 'missing-sitekey.react-native.hcaptcha.com';
}

return url;
};

function buildHcaptchaLoaderConfig({
scriptSource,
siteKey,
hl,
theme,
host,
sentry,
endpoint,
assethost,
imghost,
reportapi,
}) {
return {
scriptSource: scriptSource || 'https://hcaptcha.com/1/api.js',
render: 'explicit',
host: getHcaptchaHost(host, siteKey),
hl,
custom: typeof theme === 'object',
sentry,
endpoint,
assethost,
imghost,
reportapi,
};
}

/**
*
* @param {*} onMessage: callback after receiving response, error, or when user cancels
Expand Down Expand Up @@ -213,13 +231,25 @@ const Hcaptcha = ({
const tokenTimeout = 120000;
const loadingTimeout = 15000;
const [isLoading, setIsLoading] = useState(true);
const isLoadingRef = useRef(true);
const journeyEnabled = Boolean(userJourney);
const hasJourneyConsumerRef = useRef(false);
const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]);
const normalizedSize = useMemo(() => normalizeSize(size), [size]);
const apiUrl = useMemo(
() => buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, normalizedTheme, host, sentry, endpoint, assethost, imghost, reportapi, orientation),
[jsSrc, siteKey, languageCode, normalizedTheme, host, sentry, endpoint, assethost, imghost, reportapi, orientation]
const loaderConfig = useMemo(
() => buildHcaptchaLoaderConfig({
scriptSource: jsSrc,
siteKey,
hl: languageCode,
theme: normalizedTheme,
host,
sentry,
endpoint,
assethost,
imghost,
reportapi,
}),
[jsSrc, siteKey, languageCode, normalizedTheme, host, sentry, endpoint, assethost, imghost, reportapi]
);

const debugInfo = useMemo(
Expand All @@ -229,17 +259,18 @@ const Hcaptcha = ({

const serializedWebViewConfig = useMemo(
() => serializeForInlineScript({
apiUrl,
loaderConfig,
backgroundColor: backgroundColor ?? '',
debugInfo,
phoneNumber: phoneNumber ?? null,
phonePrefix: phonePrefix ?? null,
orientation: orientation ?? null,
rqdata: rqdata ?? null,
siteKey: siteKey || '',
size: normalizedSize,
theme: normalizedTheme,
}),
[apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, siteKey]
[loaderConfig, backgroundColor, debugInfo, normalizedSize, normalizedTheme, orientation, phoneNumber, phonePrefix, rqdata, siteKey]
);

const generateTheWebViewContent = useMemo(
Expand All @@ -254,13 +285,19 @@ const Hcaptcha = ({
var hcaptchaConfig = ${serializedWebViewConfig};
Object.entries(hcaptchaConfig.debugInfo || {}).forEach(function (entry) { window[entry[0]] = entry[1] });
</script>
<script type="text/javascript">
${hCaptchaLoaderInlineScript}
</script>
<script type="text/javascript">
var loadApiScript = function() {
var script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = hcaptchaConfig.apiUrl;
document.head.appendChild(script);
if (typeof window.hCaptchaLoader !== 'function') {
window.ReactNativeWebView.postMessage('error');
return;
}

window.hCaptchaLoader(hcaptchaConfig.loaderConfig).then(onloadCallback).catch(function(error) {
window.ReactNativeWebView.postMessage((error && error.message) || (error && error.name) || 'error');
});
};
var hcaptchaWidgetId = null;
var setData = function(data) {
Expand All @@ -275,7 +312,7 @@ const Hcaptcha = ({
var onloadCallback = function() {
try {
console.log("challenge onload starting");
hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size));
hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size, hcaptchaConfig.orientation));
window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}");
// have loaded by this point; render is sync.
console.log("challenge render complete");
Expand All @@ -301,7 +338,7 @@ const Hcaptcha = ({
console.warn("challenge error callback fired");
window.ReactNativeWebView.postMessage(error);
};
const getRenderConfig = function(siteKey, theme, size) {
const getRenderConfig = function(siteKey, theme, size, orientation) {
var config = {
sitekey: siteKey,
size: size,
Expand All @@ -315,6 +352,9 @@ const Hcaptcha = ({
if (theme) {
config.theme = theme;
}
if (orientation) {
config.orientation = orientation;
}
return config;
};
loadApiScript();
Expand Down Expand Up @@ -345,13 +385,13 @@ const Hcaptcha = ({

useEffect(() => {
const timeoutId = setTimeout(() => {
if (isLoading) {
if (isLoadingRef.current) {
onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } });
}
}, loadingTimeout);

return () => clearTimeout(timeoutId);
}, [isLoading, onMessage]);
}, [onMessage]);

const webViewRef = useRef(null);
const injectVerifyData = (resetFirst = false) => {
Expand Down Expand Up @@ -381,6 +421,14 @@ const Hcaptcha = ({
injectVerifyData(true);
};

const retryApiLoad = () => {
if (!webViewRef.current) {
return;
}

webViewRef.current.injectJavaScript('loadApiScript(); true;');
};

return (
<View style={styles.container}>
<WebView
Expand All @@ -407,15 +455,21 @@ const Hcaptcha = ({
}}
mixedContentMode={'always'}
onMessage={(e) => {
isLoadingRef.current = false;
setIsLoading(false);

if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) {
injectVerifyData();
return;
}

e.reset = reset;
if (e.nativeEvent.data === 'script-error') {
e.reset = retryApiLoad;
} else {
e.reset = reset;
}
e.success = true;
if (e.nativeEvent.data === 'open') {
setIsLoading(false);
} else if (e.nativeEvent.data.length > 35) {
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout);
e.markUsed = () => clearTimeout(expiredTokenTimerId);
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ If your app encounters an `error` event, you can reset the hCaptcha SDK flow by

`event.reset()` rebuilds the verification payload from the current props and the current buffered journey immediately before retrying.

The SDK automatically retries loading `api.js` after transient failures. If all attempts fail, `script-error` is sent via `onMessage`.

The 15-second `loading timeout` message does not stop initialization and should not be treated as a terminal loading failure.

## Dependencies

1. [react-native-webview](https://github.com/react-native-community/react-native-webview)
Expand Down Expand Up @@ -285,7 +289,6 @@ Otherwise, you should pass in the preferred device locale, e.g. fetched from `ge
- The UI defaults to the "invisible" mode of the JS SDK, i.e. no checkbox is displayed.
- If you need to test displaying the challenge modal, set your sitekey to "Always Challenge" mode in the hCaptcha dashboard.
- You can `import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha';` to customize the UI yourself.
- hCaptcha loading is restricted to a 15-second timeout; an `error` will be sent via `onMessage` if it fails to load due to network issues.

## Journey Capture Caveats

Expand Down Expand Up @@ -353,7 +356,7 @@ For new code, prefer:
| 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`. |
| 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 | sentry error reporting (see Enterprise docs) |
| 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.) |
| endpoint | string | Point hCaptcha JS Ajax Requests to alternative API Endpoint. Default: https://api.hcaptcha.com (Override only if using first-party hosting feature.) |
| reportapi | string | Point hCaptcha Bug Reporting Request to alternative API Endpoint. Default: https://accounts.hcaptcha.com (Override only if using first-party hosting feature.) |
Expand Down
3 changes: 3 additions & 0 deletions __mocks__/global.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
jest.mock('react-native-webview');
jest.mock('@hcaptcha/loader/inline', () => (
'window.hCaptchaLoader = function(config) { window.hCaptchaLoaderConfig = config; return Promise.resolve(window.hcaptcha); };'
));
jest.mock('react', () => {
let ActualReact = jest.requireActual('react');
return {
Expand Down
6 changes: 4 additions & 2 deletions __scripts__/generate-example.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ function main({ cliName, projectRelativeProjectPath, projectName, projectTemplat
const mainPackage = '@hcaptcha/react-native-hcaptcha';
const libRoot = process.cwd();
const libPathFromProject = path.relative(projectPath, libRoot);
const projectPackage = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8'));
const reactNativeVersion = projectPackage.dependencies['react-native'];
const peerPackages = 'react-native-webview';
const devPackages = 'typescript @babel/preset-env';
const devPackages = `typescript @react-native/jest-preset@${reactNativeVersion}`;

console.warn('Installing dependencies...');
if (packageManager === 'yarn') {
Expand All @@ -161,7 +163,7 @@ function main({ cliName, projectRelativeProjectPath, projectName, projectTemplat
const excludes = ['__e2e__/host', '__tests__', '__mocks__', 'node_modules', '.git', 'output', '.reassure'].map(e => `--exclude=${e}`).join(' ');
execSync(`rsync -a ${excludes} ${libRoot}/ ${destLibDir}/`, { stdio: 'inherit' });
execSync('npm i --save file:./react-native-hcaptcha', packageManagerOptions);
execSync(`npm i --save --dev ${devPackages}`, packageManagerOptions);
execSync(`npm i --save-dev ${devPackages}`, packageManagerOptions);
execSync(`npm i --save ${peerPackages}`, packageManagerOptions);
}

Expand Down
Loading
Loading