diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d1caf41..1356bb1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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: @@ -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 diff --git a/Example.jest.config.js b/Example.jest.config.js index 78a0058..d331797 100644 --- a/Example.jest.config.js +++ b/Example.jest.config.js @@ -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: ['/react-native-hcaptcha/'], diff --git a/Hcaptcha.js b/Hcaptcha.js index f4cb9d3..2936b4c 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -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'; @@ -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 @@ -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( @@ -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( @@ -254,13 +285,19 @@ const Hcaptcha = ({ var hcaptchaConfig = ${serializedWebViewConfig}; Object.entries(hcaptchaConfig.debugInfo || {}).forEach(function (entry) { window[entry[0]] = entry[1] }); + ']).toBe(''); - expect(query.hl).toBe('en"'); - expect(query.host).toBe(encodeURIComponent('host"')); - expect(query.endpoint).toBe('https://example.com/endpoint?'); - expect(query.reportapi).toBe('https://example.com/reportapi?'); - expect(query.assethost).toBe('https://example.com/assethost?'); - expect(query.imghost).toBe('https://example.com/imghost?'); - expect(query.orientation).toBe('landscape"'); - expect(query.sentry).toBe('true'); - expect(query.custom).toBe('true'); + expect(loaderConfig.scriptSource).toBe('https://example.com/api.js?x='); + expect(loaderConfig.hl).toBe('en"'); + expect(loaderConfig.host).toBe('host"'); + expect(loaderConfig.endpoint).toBe('https://example.com/endpoint?'); + expect(loaderConfig.reportapi).toBe('https://example.com/reportapi?'); + expect(loaderConfig.assethost).toBe('https://example.com/assethost?'); + expect(loaderConfig.imghost).toBe('https://example.com/imghost?'); + expect(config.orientation).toBe('landscape"'); + expect(loaderConfig.orientation).toBeUndefined(); + expect(loaderConfig.sentry).toBe(true); + expect(loaderConfig.custom).toBe(true); }); it('does not render a loading overlay when showLoading is false', () => { @@ -373,6 +446,31 @@ describe('Hcaptcha', () => { }); }); + it('does not emit a loading timeout after the widget becomes ready in passive flows', () => { + jest.useFakeTimers(); + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + jest.advanceTimersByTime(15000); + }); + + expect(onMessage).not.toHaveBeenCalledWith({ + nativeEvent: { + data: 'error', + description: 'loading timeout', + }, + }); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); + }); + it('forwards open messages, marks them as successful, and hides the loading overlay', async () => { const onMessage = jest.fn(); setWebViewMessageData('open'); @@ -515,6 +613,35 @@ describe('Hcaptcha', () => { }); }); + it('restarts the API loader when resetting a terminal script load error', () => { + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: 'script-error' } }); + }); + + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + reset: expect.any(Function), + nativeEvent: expect.objectContaining({ data: 'script-error' }), + })); + + const [{ reset }] = onMessage.mock.calls[0]; + + act(() => { + reset(); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith('loadApiScript(); true;'); + }); + it('uses verifyParams over legacy props and injects buffered journey data', () => { initJourneyTracking(); emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap', x: 1, y: 2 }); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index f7c227f..d309654 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,16 +112,22 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() + +