From f146c2fc05b6903387cf027828bb61ae6fec9fba Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Tue, 25 Aug 2026 22:55:58 +0300 Subject: [PATCH 1/9] refactor: extract shared hCaptcha helpers out of Hcaptcha.js Moves the platform-agnostic helpers (theme/size normalization, debug info, verify data, loader config, render config, shared timeouts) into hcaptchaShared.js, and isolates the `react-native/Libraries/Core/ReactNativeVersion` deep import behind reactNativeVersion.js. That import is the one thing in the library no web bundler can resolve once `react-native` is aliased to `react-native-web`, so it needs a platform split before a web build is possible. Hcaptcha.js still re-exports buildDebugInfo, buildVerifyData and HCAPTCHA_READY_EVENT, so the native behaviour and the existing tests are unchanged. Also fixes one latent bug carried over in the move: `custom` was computed as `typeof theme === 'object'`, and because `typeof null === 'object'` an absent theme told the loader to expect a custom theme that never arrives. Now guarded with an explicit null check. No test pinned the old behaviour, and ConfirmHcaptcha masked it by defaulting `theme` to 'light'; only direct `Hcaptcha` consumers without a theme were affected. Revert this hunk alone if the wire change is unwanted. Co-Authored-By: Claude Opus 5 (1M context) --- Hcaptcha.js | 167 ++++------------------------------ hcaptchaShared.js | 185 ++++++++++++++++++++++++++++++++++++++ reactNativeVersion.js | 3 + reactNativeVersion.web.js | 5 ++ 4 files changed, 209 insertions(+), 151 deletions(-) create mode 100644 hcaptchaShared.js create mode 100644 reactNativeVersion.js create mode 100644 reactNativeVersion.web.js diff --git a/Hcaptcha.js b/Hcaptcha.js index 2936b4c..3adff66 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -1,11 +1,20 @@ 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'; +import { ActivityIndicator, Linking, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; -import md5 from './md5'; -import hcaptchaPackage from './package.json'; +import { + buildDebugInfo, + buildHcaptchaLoaderConfig, + buildVerifyData, + HCAPTCHA_READY_EVENT, + LOADING_TIMEOUT, + normalizeSize, + normalizeTheme, + serializeForInlineScript, + TOKEN_MIN_LENGTH, + TOKEN_TIMEOUT, +} from './hcaptchaShared'; import { clearJourneyEvents, disableJourneyConsumer, @@ -27,151 +36,9 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; -const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; - -const serializeForInlineScript = (value) => - JSON.stringify(value) - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026') - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029'); - -const normalizeTheme = (value) => { - if (value == null) { - return null; - } - - if (typeof value === 'object') { - return value; - } - - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch (_) { - return value; - } - } - - return value; -}; - -const normalizeSize = (value) => { - if (value == null) { - return 'invisible'; - } - - return value === 'checkbox' ? 'normal' : value; -}; - -const getVersionPart = (value) => ( - typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 100 - ? value - : null -); - -const parseReactNativeVersion = (value) => { - const candidate = value && typeof value === 'object' && value.version ? value.version : value; - const major = getVersionPart(candidate?.major); - const minor = getVersionPart(candidate?.minor); - const patch = getVersionPart(candidate?.patch); - - if (major == null || minor == null || patch == null) { - return null; - } - - return { major, minor, patch }; -}; - -const getReactNativeVersion = (value = Platform?.constants?.reactNativeVersion) => - parseReactNativeVersion(value) || parseReactNativeVersion(ReactNativeVersion?.version); - -const buildDebugInfo = (debug, reactNativeVersion = Platform?.constants?.reactNativeVersion) => { - const result = { ...(debug || {}) }; - - try { - const version = getReactNativeVersion(reactNativeVersion); - if (version) { - result[`rnver_${version.major}_${version.minor}_${version.patch}`] = true; - } - result['dep_' + md5(Object.keys(global).join(''))] = true; - result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true; - } catch (e) { - console.log(e); - } - - return result; -}; - -const buildVerifyData = ({ - phoneNumber, - phonePrefix, - rqdata, - userJourney, - verifyParams, -}) => { - const normalizedVerifyParams = verifyParams || {}; - const data = {}; - const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined; - const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined; - const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined; - - if (finalRqdata) { - data.rqdata = finalRqdata; - } - if (finalPhonePrefix) { - data.mfa_phoneprefix = finalPhonePrefix; - } - if (finalPhoneNumber) { - data.mfa_phone = finalPhoneNumber; - } - if (Array.isArray(userJourney) && userJourney.length > 0) { - data.userjourney = userJourney; - } - - return data; -}; - const buildVerifyInjectionScript = (payload, resetFirst = false) => `try { ${resetFirst ? 'reset(); ' : ''}setData(${serializeForInlineScript(payload)}); execute(); } catch (e) { window.ReactNativeWebView.postMessage((e && e.name) || 'error'); } true;`; -const getHcaptchaHost = (host, siteKey) => { - if (host) { - return host; - } else if (siteKey) { - return `${siteKey}.react-native.hcaptcha.com`; - } else { - return 'missing-sitekey.react-native.hcaptcha.com'; - } -}; - -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 @@ -228,8 +95,6 @@ const Hcaptcha = ({ verifyParams, _journeyManagedExternally, }) => { - const tokenTimeout = 120000; - const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); const isLoadingRef = useRef(true); const journeyEnabled = Boolean(userJourney); @@ -388,7 +253,7 @@ const Hcaptcha = ({ if (isLoadingRef.current) { onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); } - }, loadingTimeout); + }, LOADING_TIMEOUT); return () => clearTimeout(timeoutId); }, [onMessage]); @@ -470,8 +335,8 @@ const Hcaptcha = ({ } 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); + } else if (e.nativeEvent.data.length > TOKEN_MIN_LENGTH) { + const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), TOKEN_TIMEOUT); e.markUsed = () => clearTimeout(expiredTokenTimerId); if (journeyEnabled) { clearJourneyEvents(); diff --git a/hcaptchaShared.js b/hcaptchaShared.js new file mode 100644 index 0000000..3eb22b9 --- /dev/null +++ b/hcaptchaShared.js @@ -0,0 +1,185 @@ +import { Platform } from 'react-native'; +import ReactNativeVersion from './reactNativeVersion'; + +import md5 from './md5'; +import hcaptchaPackage from './package.json'; + +export const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; + +/** + * Token responses are always longer than this; anything shorter that arrives on the + * message channel is an event name or an error code. + */ +export const TOKEN_MIN_LENGTH = 35; + +export const TOKEN_TIMEOUT = 120000; + +export const LOADING_TIMEOUT = 15000; + +export const serializeForInlineScript = (value) => + JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + +export const normalizeTheme = (value) => { + if (value == null) { + return null; + } + + if (typeof value === 'object') { + return value; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (_) { + return value; + } + } + + return value; +}; + +export const normalizeSize = (value) => { + if (value == null) { + return 'invisible'; + } + + return value === 'checkbox' ? 'normal' : value; +}; + +const getVersionPart = (value) => ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 100 + ? value + : null +); + +const parseReactNativeVersion = (value) => { + const candidate = value && typeof value === 'object' && value.version ? value.version : value; + const major = getVersionPart(candidate?.major); + const minor = getVersionPart(candidate?.minor); + const patch = getVersionPart(candidate?.patch); + + if (major == null || minor == null || patch == null) { + return null; + } + + return { major, minor, patch }; +}; + +export const getReactNativeVersion = (value = Platform?.constants?.reactNativeVersion) => + parseReactNativeVersion(value) || parseReactNativeVersion(ReactNativeVersion?.version); + +export const buildDebugInfo = (debug, reactNativeVersion = Platform?.constants?.reactNativeVersion) => { + const result = { ...(debug || {}) }; + + try { + const version = getReactNativeVersion(reactNativeVersion); + if (version) { + result[`rnver_${version.major}_${version.minor}_${version.patch}`] = true; + } + result['dep_' + md5(Object.keys(global).join(''))] = true; + result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true; + } catch (e) { + console.log(e); + } + + return result; +}; + +export const buildVerifyData = ({ + phoneNumber, + phonePrefix, + rqdata, + userJourney, + verifyParams, +}) => { + const normalizedVerifyParams = verifyParams || {}; + const data = {}; + const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined; + const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined; + const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined; + + if (finalRqdata) { + data.rqdata = finalRqdata; + } + if (finalPhonePrefix) { + data.mfa_phoneprefix = finalPhonePrefix; + } + if (finalPhoneNumber) { + data.mfa_phone = finalPhoneNumber; + } + if (Array.isArray(userJourney) && userJourney.length > 0) { + data.userjourney = userJourney; + } + + return data; +}; + +export const getHcaptchaHost = (host, siteKey) => { + if (host) { + return host; + } else if (siteKey) { + return `${siteKey}.react-native.hcaptcha.com`; + } else { + return 'missing-sitekey.react-native.hcaptcha.com'; + } +}; + +export 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, + // `typeof null === 'object'`, so an absent theme must be excluded explicitly — + // otherwise the loader is told to expect a custom theme that never arrives. + custom: typeof theme === 'object' && theme !== null, + sentry, + endpoint, + assethost, + imghost, + reportapi, + }; +} + +/** + * Render config passed to `hcaptcha.render`. Shared so the web widget and the + * WebView-hosted widget stay configured identically. + */ +export function buildRenderConfig({ siteKey, theme, size, orientation, callbacks }) { + const config = { + sitekey: siteKey, + size, + callback: callbacks.onData, + 'close-callback': callbacks.onCancel, + 'open-callback': callbacks.onOpen, + 'expired-callback': callbacks.onDataExpired, + 'chalexpired-callback': callbacks.onChalExpired, + 'error-callback': callbacks.onDataError, + }; + + if (theme) { + config.theme = theme; + } + if (orientation) { + config.orientation = orientation; + } + + return config; +} diff --git a/reactNativeVersion.js b/reactNativeVersion.js new file mode 100644 index 0000000..20d0b98 --- /dev/null +++ b/reactNativeVersion.js @@ -0,0 +1,3 @@ +import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; + +export default ReactNativeVersion; diff --git a/reactNativeVersion.web.js b/reactNativeVersion.web.js new file mode 100644 index 0000000..d9888b9 --- /dev/null +++ b/reactNativeVersion.web.js @@ -0,0 +1,5 @@ +// react-native-web does not ship `react-native/Libraries/Core/ReactNativeVersion`, +// and bundlers fail to resolve that path once `react-native` is aliased to +// `react-native-web`. On web `Platform.constants.reactNativeVersion` is the only +// version source, and `getReactNativeVersion` already reads it first. +export default null; From 60e7d938f4c0dec75be9118080152ac60b6d3a9f Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Tue, 25 Aug 2026 22:56:25 +0300 Subject: [PATCH 2/9] feat: support react-native-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a web build so the same ConfirmHcaptcha / Hcaptcha code runs under react-native-web with no application changes. Rather than emulating a WebView in the browser, Hcaptcha.web.js loads api.js with @hcaptcha/loader — already a dependency, and the same loader the native inline HTML uses — and renders the widget directly into the document. That keeps setData/execute/reset as direct API calls instead of messages that need an injectJavaScript transport, so the full native feature set carries over: the { nativeEvent: { data } } event shape, success/reset/markUsed, size and theme normalization, rqdata, verifyParams, MFA phone props, User Journeys, the 15s loading timeout, the 120s token expiry and script-error retry. react-native-web supplies Modal, SafeAreaView and the rest, so no Modal, animation or WebView shims are needed; `react-native` -> `react-native-web` is the only bundler alias. react-native-web and react-dom are declared as optional peer dependencies, so native-only installs are unaffected. Testing: - __tests_web__/ holds a jsdom Jest project (`npm run test:web`, 27 tests). It stubs only the network fetch of api.js and the widget API it installs on `window`; the component renders for real. It lives outside __tests__ so the native project's default testMatch cannot pick it up. - `npm test` now runs both projects. - Verified end to end in a real browser against live hCaptcha via the new `npm run web` example: the widget loads, a visual challenge opens inside the RNW modal, and with hCaptcha's always-pass test key the token reaches onMessage and markUsed()/hide() fire. Also adds `modulePathIgnorePatterns` for __e2e__ to the native Jest config. A generated __e2e__/host app carries its own node_modules including a second copy of React, which Jest resolved into, failing 32 unit tests locally. Co-Authored-By: Claude Opus 5 (1M context) --- .eslintignore | 8 + .eslintrc.js | 11 + .gitignore | 3 + Example.Web.js | 97 + Hcaptcha.web.js | 309 + README.md | 62 +- __mocks__/web.js | 92 + __tests_web__/ConfirmHcaptcha.test.web.js | 117 + __tests_web__/Hcaptcha.test.web.js | 320 + .../ConfirmHcaptcha.test.web.js.snap | 62 + .../__snapshots__/Hcaptcha.test.web.js.snap | 50 + index.html | 21 + jest.web.config.js | 24 + package-lock.json | 15263 ++++++++++------ package.json | 41 +- webpack.web.config.js | 41 + 16 files changed, 10982 insertions(+), 5539 deletions(-) create mode 100644 .eslintignore create mode 100644 Example.Web.js create mode 100644 Hcaptcha.web.js create mode 100644 __mocks__/web.js create mode 100644 __tests_web__/ConfirmHcaptcha.test.web.js create mode 100644 __tests_web__/Hcaptcha.test.web.js create mode 100644 __tests_web__/__snapshots__/ConfirmHcaptcha.test.web.js.snap create mode 100644 __tests_web__/__snapshots__/Hcaptcha.test.web.js.snap create mode 100644 index.html create mode 100644 jest.web.config.js create mode 100644 webpack.web.config.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..0d9a82f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ +# Generated web example bundle +dist/ +# Generated E2E host app +__e2e__/host/ +# Reassure output +.reassure/ +output/ diff --git a/.eslintrc.js b/.eslintrc.js index 187894b..0bc60c7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { root: true, extends: '@react-native', + overrides: [ + { + // The react-native-web suite lives outside __tests__ so the native Jest project + // does not pick it up; ESLint still needs the Jest globals here. + files: ['**/__tests_web__/**/*.js'], + env: { + jest: true, + 'jest/globals': true, + }, + }, + ], }; diff --git a/.gitignore b/.gitignore index 0be1efb..c4526f1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ output/ # Generated E2E host app __e2e__/host/ + +# Web example build output +dist/ diff --git a/Example.Web.js b/Example.Web.js new file mode 100644 index 0000000..ba10308 --- /dev/null +++ b/Example.Web.js @@ -0,0 +1,97 @@ +import React, { useRef, useState } from 'react'; +import { AppRegistry, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import ConfirmHcaptcha from './index'; + +// demo sitekey +// Swap in hCaptcha's always-pass test key '10000000-ffff-ffff-ffff-000000000001' +// to exercise the token path without solving a visual challenge. +const siteKey = '00000000-0000-0000-0000-000000000000'; +const baseUrl = '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); + } + } + }; + + return ( + + hCaptcha · React Native Web + + { + captchaForm.current.show(); + }} + testID="launch-button"> + Click to launch + + {code && ( + + {'passcode or status: '} + + {code} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + backgroundColor: '#ecf0f1', + padding: 8, + }, + heading: { + fontSize: 22, + fontWeight: 'bold', + textAlign: 'center', + }, + paragraph: { + margin: 24, + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + }, + codeContainer: { + alignSelf: 'center', + }, + codeText: { + color: 'darkviolet', + fontSize: 10, + fontWeight: 'bold', + }, +}); + +AppRegistry.registerComponent('HcaptchaWebExample', () => App); +AppRegistry.runApplication('HcaptchaWebExample', { + rootTag: document.getElementById('root'), +}); + +export default App; diff --git a/Hcaptcha.web.js b/Hcaptcha.web.js new file mode 100644 index 0000000..bf8b08b --- /dev/null +++ b/Hcaptcha.web.js @@ -0,0 +1,309 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { hCaptchaLoader } from '@hcaptcha/loader'; +import { ActivityIndicator, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; + +import { + buildDebugInfo, + buildHcaptchaLoaderConfig, + buildRenderConfig, + buildVerifyData, + LOADING_TIMEOUT, + normalizeSize, + normalizeTheme, + TOKEN_MIN_LENGTH, + TOKEN_TIMEOUT, +} from './hcaptchaShared'; +import { + clearJourneyEvents, + disableJourneyConsumer, + enableJourneyConsumer, + peekJourneyEvents, +} from './journey'; + +/** + * Web implementation of the hCaptcha component. + * + * On native the widget is isolated inside a WebView and the two sides talk over + * `postMessage` / `injectJavaScript`. On web the host page *is* a browser, so the + * widget is rendered straight into the document with `@hcaptcha/loader` — the same + * loader the native HTML uses — and the message channel collapses into direct calls. + * + * The `onMessage` contract is deliberately identical to the native one: every event is + * delivered as `{ nativeEvent: { data } }` carrying `success`, `reset` and, for tokens, + * `markUsed`. That keeps `index.js`, the public API and the type definitions unchanged. + */ +const Hcaptcha = ({ + onMessage, + size, + siteKey, + style, + languageCode, + showLoading, + closableLoading, + loadingIndicatorColor, + theme, + rqdata, + sentry, + jsSrc, + endpoint, + reportapi, + assethost, + imghost, + host, + debug, + orientation, + phonePrefix, + phoneNumber, + userJourney, + verifyParams, + _journeyManagedExternally, +}) => { + const [isLoading, setIsLoading] = useState(true); + const isLoadingRef = useRef(true); + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const hasRenderedRef = useRef(false); + const journeyEnabled = Boolean(userJourney); + const hasJourneyConsumerRef = useRef(false); + // Declared up front: `emit` and the widget callbacks are mutually recursive with the + // loader, so both are reached through refs that are populated further down. + const emitRef = useRef(null); + const callbacksRef = useRef(null); + + const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]); + const normalizedSize = useMemo(() => normalizeSize(size), [size]); + + 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(() => buildDebugInfo(debug), [debug]); + + // The native build exposes these as globals inside its WebView, where hCaptcha reads + // them. On web the widget runs in the host document, so they go on `window` instead. + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + Object.entries(debugInfo || {}).forEach(([key, value]) => { + window[key] = value; + }); + }, [debugInfo]); + + // Latest verify inputs, read at execute time rather than captured at render time so + // journey events buffered after mount are still included. + const verifyInputsRef = useRef(null); + verifyInputsRef.current = { phoneNumber, phonePrefix, rqdata, verifyParams }; + + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + + const getApi = () => (typeof window === 'undefined' ? null : window.hcaptcha); + + const applyVerifyData = useCallback((resetFirst = false) => { + const api = getApi(); + const widgetId = widgetIdRef.current; + + if (!api || widgetId == null) { + return; + } + + const { phoneNumber: pn, phonePrefix: pp, rqdata: rq, verifyParams: vp } = verifyInputsRef.current; + + try { + if (resetFirst) { + api.reset(widgetId); + } + api.setData(widgetId, buildVerifyData({ + phoneNumber: pn, + phonePrefix: pp, + rqdata: rq, + userJourney: journeyEnabled ? peekJourneyEvents() : undefined, + verifyParams: vp, + })); + api.execute(widgetId); + } catch (e) { + emitRef.current((e && e.name) || 'error'); + } + }, [journeyEnabled]); + + const reset = useCallback(() => applyVerifyData(true), [applyVerifyData]); + + const loadApiScript = useCallback(() => { + hCaptchaLoader(loaderConfig) + .then(() => { + const api = getApi(); + const container = containerRef.current; + + if (!api || !container || hasRenderedRef.current) { + return; + } + + try { + widgetIdRef.current = api.render(container, buildRenderConfig({ + siteKey: siteKey || '', + theme: normalizedTheme, + size: normalizedSize, + orientation, + callbacks: callbacksRef.current, + })); + hasRenderedRef.current = true; + applyVerifyData(); + } catch (e) { + emitRef.current((e && e.name) || 'error'); + } + }) + .catch((error) => { + emitRef.current((error && error.message) || (error && error.name) || 'error'); + }); + }, [loaderConfig, siteKey, normalizedTheme, normalizedSize, orientation, applyVerifyData]); + + const retryApiLoad = useCallback(() => { + hasRenderedRef.current = false; + widgetIdRef.current = null; + loadApiScript(); + }, [loadApiScript]); + + /** + * Mirrors the native `onMessage` handler exactly: same loading-state side effect, + * same `reset` selection, same token detection, same expiry timer. + */ + const emit = useCallback((data, description) => { + isLoadingRef.current = false; + setIsLoading(false); + + const event = { nativeEvent: { data } }; + if (description !== undefined) { + event.nativeEvent.description = description; + } + + event.reset = data === 'script-error' ? retryApiLoad : reset; + event.success = true; + + if (data === 'open') { + // No extra handling; parity with native. + } else if (typeof data === 'string' && data.length > TOKEN_MIN_LENGTH) { + const expiredTokenTimerId = setTimeout( + () => onMessageRef.current({ nativeEvent: { data: 'expired' }, success: false, reset }), + TOKEN_TIMEOUT + ); + event.markUsed = () => clearTimeout(expiredTokenTimerId); + if (journeyEnabled) { + clearJourneyEvents(); + } + } else /* error */ { + event.success = false; + } + + onMessageRef.current(event); + }, [journeyEnabled, reset, retryApiLoad]); + + emitRef.current = emit; + + callbacksRef.current = { + onData: (response) => emitRef.current(response), + onCancel: () => emitRef.current('challenge-closed'), + onOpen: () => emitRef.current('open'), + onDataExpired: (error) => emitRef.current(error || 'expired'), + onChalExpired: (error) => emitRef.current(error || 'challenge-expired'), + onDataError: (error) => emitRef.current(error || 'error'), + }; + + useEffect(() => { + if (_journeyManagedExternally || !journeyEnabled || hasJourneyConsumerRef.current) { + return undefined; + } + + enableJourneyConsumer(); + hasJourneyConsumerRef.current = true; + + return () => { + if (hasJourneyConsumerRef.current) { + disableJourneyConsumer(); + hasJourneyConsumerRef.current = false; + } + }; + }, [_journeyManagedExternally, journeyEnabled]); + + useEffect(() => { + const timeoutId = setTimeout(() => { + if (isLoadingRef.current) { + onMessageRef.current({ nativeEvent: { data: 'error', description: 'loading timeout' } }); + } + }, LOADING_TIMEOUT); + + return () => clearTimeout(timeoutId); + }, []); + + useEffect(() => { + loadApiScript(); + + return () => { + const api = getApi(); + const widgetId = widgetIdRef.current; + + if (api && widgetId != null && typeof api.remove === 'function') { + try { + api.remove(widgetId); + } catch (e) { + // The widget may already be gone; nothing actionable here. + } + } + + widgetIdRef.current = null; + hasRenderedRef.current = false; + }; + // Re-running on every config change would tear down an in-flight challenge, so the + // widget is created once per mount — matching the native WebView, which is also only + // built from the initial props. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const renderLoading = () => ( + closableLoading && onMessageRef.current({ nativeEvent: { data: 'cancel' } })}> + + + + + ); + + return ( + + + {showLoading && isLoading && renderLoading()} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + }, + widget: { + alignItems: 'center', + backgroundColor: 'transparent', + flex: 1, + justifyContent: 'center', + width: '100%', + }, +}); + +export default Hcaptcha; +export { buildDebugInfo, buildVerifyData, HCAPTCHA_READY_EVENT } from './hcaptchaShared'; diff --git a/README.md b/README.md index 3e0c5eb..45b45e0 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,69 @@ The SDK automatically retries loading `api.js` after transient failures. If all The 15-second `loading timeout` message does not stop initialization and should not be treated as a terminal loading failure. +## React Native Web + +The library ships a web build, so the same `ConfirmHcaptcha` / `Hcaptcha` code runs +under [react-native-web](https://necolas.github.io/react-native-web/) with no changes +to your app. + +### Install + +```bash +npm install react-native-web react-dom +``` + +Both are optional peer dependencies — native-only apps do not need them. Alias +`react-native` to `react-native-web` in your bundler, exactly as react-native-web +requires. No WebView shim, `Modal` stub or animation stub is needed: react-native-web +provides those components, and the web build renders hCaptcha directly. + +### How it differs from native + +On native the widget is isolated inside a `WebView` and the two sides talk over +`postMessage` / `injectJavaScript`. On web the host page is already a browser, so +`Hcaptcha.web.js` loads `api.js` with [`@hcaptcha/loader`](https://github.com/hCaptcha/hcaptcha-loader) +and renders the widget straight into the document. + +The public API is unchanged. `onMessage` receives the same +`{ nativeEvent: { data } }` events with the same `success`, `reset` and `markUsed` +fields, and `size`, `theme`, `rqdata`, `verifyParams`, MFA phone props, User Journeys, +the 15-second loading timeout and the 120-second token expiry all behave as documented +above. + +Three behaviours necessarily differ: + +| Prop / behaviour | On web | +| --- | --- | +| `backgroundColor` | Not applied to the page. On native it tints the WebView document body; on web that document is your app's own page, so the library leaves it alone. The `ConfirmHcaptcha` modal backdrop still uses it. | +| `debug` | Debug markers are set on `window` rather than inside a WebView, because the widget now runs in the host document. | +| `sms:` links | Handled by the browser and the hCaptcha widget directly; there is no WebView navigation to intercept, so no `sms-open-failed` event is emitted. | + +### Run the example + +```bash +npm run web +``` + +Serves `Example.Web.js` on . To exercise the token path without +solving a visual challenge, switch the example's `siteKey` to hCaptcha's always-pass +test key `10000000-ffff-ffff-ffff-000000000001`. + +### Tests + +```bash +npm run test:native # react-native suite +npm run test:web # react-native-web suite (jsdom) +npm test # both +``` + +The web suite stubs only the network fetch of `api.js` and the widget API it installs +on `window`; the component itself renders for real. + ## Dependencies -1. [react-native-webview](https://github.com/react-native-community/react-native-webview) +1. [react-native-webview](https://github.com/react-native-community/react-native-webview) (native only) +2. [react-native-web](https://necolas.github.io/react-native-web/) and `react-dom` (web only, optional) ## Building on iOS diff --git a/__mocks__/web.js b/__mocks__/web.js new file mode 100644 index 0000000..1dcbaf6 --- /dev/null +++ b/__mocks__/web.js @@ -0,0 +1,92 @@ +// Web test setup. +// +// Deliberately narrow: the only thing stubbed is the *network* fetch of hCaptcha's +// api.js and the widget API it installs on `window`. The component under test — +// Hcaptcha.web.js and everything it renders — runs for real, so these tests exercise +// the actual render path, loader wiring and event mapping. + +const createHcaptchaMock = () => { + const state = { + // Controls what the loader does on the next call. `hang` models an api.js request + // that never settles, which is what the loading timeout exists for. + loader: { shouldFail: false, error: null, hang: false }, + lastLoaderConfig: null, + loadCount: 0, + // Recorded widget interactions. + renderConfig: null, + renderTarget: null, + renderCount: 0, + renderThrows: false, + setDataCalls: [], + executeCount: 0, + resetCount: 0, + removeCount: 0, + widgetId: 'test-widget-id', + }; + + state.api = { + render: (target, config) => { + state.renderCount += 1; + state.renderTarget = target; + state.renderConfig = config; + if (state.renderThrows) { + const error = new Error('render failed'); + error.name = 'RenderError'; + throw error; + } + return state.widgetId; + }, + setData: (widgetId, data) => { + state.setDataCalls.push({ widgetId, data }); + }, + execute: () => { + state.executeCount += 1; + }, + reset: () => { + state.resetCount += 1; + }, + remove: () => { + state.removeCount += 1; + }, + }; + + // Invokes one of the callbacks the component handed to `hcaptcha.render`. + state.fire = (name, ...args) => { + const handler = state.renderConfig && state.renderConfig[name]; + if (typeof handler !== 'function') { + throw new Error(`No hCaptcha callback registered for "${name}"`); + } + return handler(...args); + }; + + return state; +}; + +global.__hcaptcha = createHcaptchaMock(); + +global.__resetHcaptchaMock = () => { + global.__hcaptcha = createHcaptchaMock(); + delete global.hcaptcha; +}; + +jest.mock('@hcaptcha/loader', () => ({ + hCaptchaLoader: (config) => { + const state = global.__hcaptcha; + state.lastLoaderConfig = config; + state.loadCount += 1; + + if (state.loader.hang) { + return new Promise(() => {}); + } + + if (state.loader.shouldFail) { + return Promise.reject(state.loader.error || new Error('script-error')); + } + + // `global` is `window` under jsdom; a jest.mock factory may not close over `window`. + global.hcaptcha = state.api; + return Promise.resolve(global.hcaptcha); + }, +})); + +jest.mock('../md5', () => () => 'mocked-md5'); diff --git a/__tests_web__/ConfirmHcaptcha.test.web.js b/__tests_web__/ConfirmHcaptcha.test.web.js new file mode 100644 index 0000000..642c583 --- /dev/null +++ b/__tests_web__/ConfirmHcaptcha.test.web.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import ConfirmHcaptcha from '../index'; + +const SITE_KEY = '00000000-0000-0000-0000-000000000000'; + +const renderConfirm = (props = {}) => { + const ref = React.createRef(); + const result = render( + {})} {...props} /> + ); + return { ...result, ref }; +}; + +const show = async (ref) => { + await act(async () => { + ref.current.show(); + }); +}; + +beforeEach(() => { + jest.useFakeTimers(); + global.__resetHcaptchaMock(); +}); + +afterEach(() => { + cleanup(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('ConfirmHcaptcha on web', () => { + it('renders nothing until show() is called', () => { + const { container } = renderConfirm(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the modal and mounts the widget after show()', async () => { + const { ref } = renderConfirm(); + + await show(ref); + + // The regression this guards: the previous react-native-web spike snapshotted + // `null` here, i.e. the modal never rendered on web at all. + const widget = screen.getByTestId('hcaptcha-container'); + expect(widget).toBeTruthy(); + expect(global.__hcaptcha.renderCount).toBe(1); + expect(global.__hcaptcha.renderTarget).toBe(widget); + }); + + it('matches the rendered DOM snapshot', async () => { + const { ref, baseElement } = renderConfirm(); + + await show(ref); + + expect(baseElement).toMatchSnapshot(); + }); + + it('emits cancel when the backdrop is pressed', async () => { + const onMessage = jest.fn(); + const { ref } = renderConfirm({ onMessage }); + + await show(ref); + fireEvent.click(screen.getByTestId('confirm-hcaptcha-backdrop')); + + expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); + expect(screen.queryByTestId('hcaptcha-container')).toBeNull(); + }); + + it('does not emit cancel when hide() is called by the consumer', async () => { + const onMessage = jest.fn(); + const { ref } = renderConfirm({ onMessage }); + + await show(ref); + await act(async () => { + ref.current.hide(); + }); + + expect(onMessage).not.toHaveBeenCalled(); + expect(screen.queryByTestId('hcaptcha-container')).toBeNull(); + }); + + it('omits the backdrop when hasBackdrop is false', async () => { + const { ref } = renderConfirm({ hasBackdrop: false }); + + await show(ref); + + expect(screen.queryByTestId('confirm-hcaptcha-backdrop')).toBeNull(); + expect(screen.getByTestId('hcaptcha-container')).toBeTruthy(); + }); + + it('mounts the widget without a modal in passive mode', async () => { + const { ref } = renderConfirm({ passiveSiteKey: true }); + + await show(ref); + + expect(screen.getByTestId('hcaptcha-container')).toBeTruthy(); + expect(screen.queryByTestId('confirm-hcaptcha-backdrop')).toBeNull(); + }); + + it('forwards a token from the widget through to onMessage', async () => { + const onMessage = jest.fn(); + const { ref } = renderConfirm({ onMessage }); + + await show(ref); + + const token = 'P0_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.a-token-longer-than-thirty-five-chars'; + act(() => { + global.__hcaptcha.fire('callback', token); + }); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage.mock.calls[0][0].nativeEvent.data).toBe(token); + expect(onMessage.mock.calls[0][0].success).toBe(true); + }); +}); diff --git a/__tests_web__/Hcaptcha.test.web.js b/__tests_web__/Hcaptcha.test.web.js new file mode 100644 index 0000000..c68bdae --- /dev/null +++ b/__tests_web__/Hcaptcha.test.web.js @@ -0,0 +1,320 @@ +import React from 'react'; +import { act, cleanup, render, screen } from '@testing-library/react'; + +import Hcaptcha from '../Hcaptcha'; + +const SITE_KEY = '00000000-0000-0000-0000-000000000000'; +const TOKEN = 'P0_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.a-token-longer-than-thirty-five-chars'; + +const defaultProps = { + siteKey: SITE_KEY, + onMessage: () => {}, + showLoading: false, +}; + +/** + * Renders and lets the mocked loader promise settle, so the widget is mounted. + */ +const renderAndLoad = async (props = {}) => { + const result = render(); + await act(async () => {}); + return result; +}; + +beforeEach(() => { + jest.useFakeTimers(); + global.__resetHcaptchaMock(); +}); + +afterEach(() => { + cleanup(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('Hcaptcha on web', () => { + it('renders a real DOM container rather than a WebView', async () => { + await renderAndLoad(); + + const container = screen.getByTestId('hcaptcha-container'); + expect(container).toBeTruthy(); + expect(container.tagName).toBe('DIV'); + expect(global.__hcaptcha.renderTarget).toBe(container); + }); + + it('matches the rendered DOM snapshot', async () => { + const { container } = await renderAndLoad({ showLoading: true }); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('configures the loader from the component props', async () => { + await renderAndLoad({ + languageCode: 'de', + jsSrc: 'https://example.test/1/api.js', + endpoint: 'https://endpoint.test', + assethost: 'https://assets.test', + imghost: 'https://imgs.test', + reportapi: 'https://report.test', + sentry: true, + }); + + expect(global.__hcaptcha.lastLoaderConfig).toMatchObject({ + scriptSource: 'https://example.test/1/api.js', + render: 'explicit', + host: `${SITE_KEY}.react-native.hcaptcha.com`, + hl: 'de', + custom: false, + sentry: true, + endpoint: 'https://endpoint.test', + assethost: 'https://assets.test', + imghost: 'https://imgs.test', + reportapi: 'https://report.test', + }); + }); + + it('renders the widget with the normalized size, theme and orientation', async () => { + await renderAndLoad({ size: 'checkbox', theme: 'dark', orientation: 'landscape' }); + + expect(global.__hcaptcha.renderConfig).toMatchObject({ + sitekey: SITE_KEY, + size: 'normal', + theme: 'dark', + orientation: 'landscape', + }); + }); + + it('parses a stringified custom theme and flags it to the loader', async () => { + await renderAndLoad({ theme: '{"palette":{"grey":{"100":"#fff"}}}' }); + + expect(global.__hcaptcha.lastLoaderConfig.custom).toBe(true); + expect(global.__hcaptcha.renderConfig.theme).toEqual({ palette: { grey: { 100: '#fff' } } }); + }); + + it('sets verify data and executes once the widget is rendered', async () => { + await renderAndLoad({ + rqdata: 'some-rqdata', + phonePrefix: '44', + phoneNumber: '+441234567890', + }); + + expect(global.__hcaptcha.setDataCalls).toEqual([ + { + widgetId: 'test-widget-id', + data: { + rqdata: 'some-rqdata', + mfa_phoneprefix: '44', + mfa_phone: '+441234567890', + }, + }, + ]); + expect(global.__hcaptcha.executeCount).toBe(1); + }); + + it('prefers verifyParams over the legacy props', async () => { + await renderAndLoad({ + rqdata: 'legacy', + verifyParams: { rqdata: 'preferred' }, + }); + + expect(global.__hcaptcha.setDataCalls[0].data).toEqual({ rqdata: 'preferred' }); + }); + + it('delivers a token as a successful message carrying markUsed and reset', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('callback', TOKEN); + }); + + expect(onMessage).toHaveBeenCalledTimes(1); + const event = onMessage.mock.calls[0][0]; + expect(event.nativeEvent.data).toBe(TOKEN); + expect(event.success).toBe(true); + expect(typeof event.markUsed).toBe('function'); + expect(typeof event.reset).toBe('function'); + }); + + it('reports the token as expired after the token timeout', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('callback', TOKEN); + }); + onMessage.mockClear(); + + act(() => { + jest.advanceTimersByTime(120000); + }); + + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + nativeEvent: { data: 'expired' }, + success: false, + })); + }); + + it('does not report expiry once the token is marked used', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('callback', TOKEN); + }); + onMessage.mock.calls[0][0].markUsed(); + onMessage.mockClear(); + + act(() => { + jest.advanceTimersByTime(120000); + }); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('maps the open callback to a successful open message', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('open-callback'); + }); + + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + nativeEvent: { data: 'open' }, + success: true, + })); + }); + + it('marks close, expiry and error callbacks as failures', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('close-callback'); + global.__hcaptcha.fire('expired-callback', 'expired'); + global.__hcaptcha.fire('chalexpired-callback', 'challenge-expired'); + global.__hcaptcha.fire('error-callback', 'rate-limited'); + }); + + expect(onMessage.mock.calls.map(([e]) => [e.nativeEvent.data, e.success])).toEqual([ + ['challenge-closed', false], + ['expired', false], + ['challenge-expired', false], + ['rate-limited', false], + ]); + }); + + it('resets the widget and re-executes when reset is called', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('error-callback', 'rate-limited'); + }); + + act(() => { + onMessage.mock.calls[0][0].reset(); + }); + + expect(global.__hcaptcha.resetCount).toBe(1); + expect(global.__hcaptcha.setDataCalls).toHaveLength(2); + expect(global.__hcaptcha.executeCount).toBe(2); + }); + + it('surfaces a loader failure and retries the API load on reset', async () => { + const onMessage = jest.fn(); + global.__hcaptcha.loader.shouldFail = true; + global.__hcaptcha.loader.error = new Error('script-error'); + + render(); + await act(async () => {}); + + expect(onMessage).toHaveBeenCalledTimes(1); + const event = onMessage.mock.calls[0][0]; + expect(event.nativeEvent.data).toBe('script-error'); + expect(event.success).toBe(false); + expect(global.__hcaptcha.loadCount).toBe(1); + + // A script-error hands back a retry, not a widget reset. + global.__hcaptcha.loader.shouldFail = false; + await act(async () => { + event.reset(); + }); + + expect(global.__hcaptcha.loadCount).toBe(2); + expect(global.__hcaptcha.renderCount).toBe(1); + }); + + it('reports a loading timeout when the api.js request never settles', async () => { + const onMessage = jest.fn(); + // An api.js fetch that hangs: no resolve, no reject, so nothing else can report. + global.__hcaptcha.loader.hang = true; + + render(); + await act(async () => {}); + + expect(onMessage).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(15000); + }); + + expect(onMessage).toHaveBeenCalledWith({ + nativeEvent: { data: 'error', description: 'loading timeout' }, + }); + }); + + it('stops reporting a loading timeout once the loader has already failed', async () => { + const onMessage = jest.fn(); + global.__hcaptcha.loader.shouldFail = true; + global.__hcaptcha.loader.error = new Error('script-error'); + + render(); + await act(async () => {}); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage.mock.calls[0][0].nativeEvent.data).toBe('script-error'); + + // Parity with native: any delivered message clears the loading state, so the + // timeout does not pile a second error on top. + act(() => { + jest.advanceTimersByTime(15000); + }); + + expect(onMessage).toHaveBeenCalledTimes(1); + }); + + it('does not report a loading timeout after the challenge opens', async () => { + const onMessage = jest.fn(); + await renderAndLoad({ onMessage }); + + act(() => { + global.__hcaptcha.fire('open-callback'); + }); + onMessage.mockClear(); + + act(() => { + jest.advanceTimersByTime(15000); + }); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('removes the widget on unmount', async () => { + const { unmount } = await renderAndLoad(); + + unmount(); + + expect(global.__hcaptcha.removeCount).toBe(1); + }); + + it('exposes the RN version marker from Platform constants only', async () => { + // `react-native/Libraries/Core/ReactNativeVersion` does not exist on web; the debug + // info must still be built without it. + await renderAndLoad({ debug: { marker: true } }); + + expect(window.marker).toBe(true); + expect(window['dep_mocked-md5']).toBe(true); + expect(window.sdk_4_1_0).toBe(true); + }); +}); diff --git a/__tests_web__/__snapshots__/ConfirmHcaptcha.test.web.js.snap b/__tests_web__/__snapshots__/ConfirmHcaptcha.test.web.js.snap new file mode 100644 index 0000000..58f5dbb --- /dev/null +++ b/__tests_web__/__snapshots__/ConfirmHcaptcha.test.web.js.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfirmHcaptcha on web matches the rendered DOM snapshot 1`] = ` + +
+
+
+