From 6a2b48b844d05bebceaf7511ff892616f786e72f Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Fri, 10 Jul 2026 18:48:41 -0300 Subject: [PATCH 01/13] Load api.js through hCaptcha Loader --- Hcaptcha.js | 23 ++++++++--- __tests__/Hcaptcha.test.js | 38 +++++++++++-------- .../ConfirmHcaptcha.test.js.snap | 21 +++++++--- __tests__/__snapshots__/Hcaptcha.test.js.snap | 21 +++++++--- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/Hcaptcha.js b/Hcaptcha.js index f4cb9d3..c148ec4 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -27,6 +27,7 @@ const patchPostMessageJsCode = `(${String(function () { })})();`; const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; +const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@latest/dist/index.es5.js'; const serializeForInlineScript = (value) => JSON.stringify(value) @@ -232,6 +233,7 @@ const Hcaptcha = ({ apiUrl, backgroundColor: backgroundColor ?? '', debugInfo, + loaderSentry: Boolean(sentry), phoneNumber: phoneNumber ?? null, phonePrefix: phonePrefix ?? null, rqdata: rqdata ?? null, @@ -239,7 +241,7 @@ const Hcaptcha = ({ size: normalizedSize, theme: normalizedTheme, }), - [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, siteKey] + [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, sentry, siteKey] ); const generateTheWebViewContent = useMemo( @@ -254,13 +256,22 @@ const Hcaptcha = ({ var hcaptchaConfig = ${serializedWebViewConfig}; Object.entries(hcaptchaConfig.debugInfo || {}).forEach(function (entry) { window[entry[0]] = entry[1] }); + ' + ); + expect(loaderMock).toHaveBeenCalledTimes(1); + const loaderParams = loaderMock.mock.calls[0][0]; + expect(loaderParams.scriptSource).toBe('https://hcaptcha.com/1/api.js'); + expect(loaderParams.sentry).toBe(false); + expect(Object.fromEntries(new URLSearchParams(loaderParams.query))).toMatchObject({ + render: 'explicit', + host: '00000000-0000-0000-0000-000000000000.react-native.hcaptcha.com', + hl: 'en', + custom: 'true', }); + expect(new URLSearchParams(loaderParams.query).has('onload')).toBe(false); expect(typeof context.onloadCallback).toBe('function'); - expect(new URL(appendedScripts[0].src).searchParams.get('onload')).toBe('onloadCallback'); - - context.onloadCallback(); expect(renderMock).toHaveBeenCalledWith('hcaptcha-container', expect.objectContaining({ sitekey: '00000000-0000-0000-0000-000000000000', diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index f7c227f..1890626 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,16 +112,25 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() + + - - - - -
- - `, + const webViewContent = useMemo( + () => generateWebViewContent({ + loaderMessagePrefix: HCAPTCHA_LOADER_PREFIX, + readyEvent: HCAPTCHA_READY_EVENT, + serializedConfig: serializedWebViewConfig, + }), [serializedWebViewConfig] ); @@ -356,15 +283,25 @@ const Hcaptcha = ({ useEffect(() => { const timeoutId = setTimeout(() => { - if (isLoading) { - onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); + if (!isLoadingRef.current) { + return; + } + + if (sentry && !apiLoadFailureReportedRef.current) { + reportApiLoadTimeout({ + attempts: apiLoadAttemptsRef.current, + elapsedMs: loadingTimeout, + jsSrc: apiScriptSource, + siteKey, + }); } + + onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); }, loadingTimeout); return () => clearTimeout(timeoutId); - }, [isLoading, onMessage]); + }, [apiScriptSource, onMessage, sentry, siteKey]); - const webViewRef = useRef(null); const injectVerifyData = (resetFirst = false) => { if (!webViewRef.current) { return; @@ -392,6 +329,77 @@ const Hcaptcha = ({ injectVerifyData(true); }; + const markLoadingComplete = () => { + if (!isLoadingRef.current) { + return; + } + + isLoadingRef.current = false; + setIsLoading(false); + }; + + const handleInternalMessage = (message) => { + switch (message.type) { + case 'load-started': + case 'load-succeeded': + apiLoadAttemptsRef.current = message.attempts; + return; + + case 'load-failed': + apiLoadAttemptsRef.current = message.attempts; + + if (sentry && !apiLoadFailureReportedRef.current) { + reportApiLoadFailure({ + attempts: message.attempts, + elapsedMs: message.elapsedMs, + jsSrc: apiScriptSource, + reason: message.reason, + siteKey, + }); + apiLoadFailureReportedRef.current = true; + } + + return; + + case 'widget-ready': + markLoadingComplete(); + injectVerifyData(); + return; + + default: + return; + } + }; + + const handleChallengeMessage = (event) => { + markLoadingComplete(); + event.reset = reset; + event.success = true; + + if (event.nativeEvent.data.length > 35) { + const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); + event.markUsed = () => clearTimeout(expiredTokenTimerId); + if (journeyEnabled) { + clearJourneyEvents(); + } + } else if (event.nativeEvent.data !== 'open') { + event.success = false; + } + + onMessage(event); + }; + + const handleWebViewMessage = (event) => { + const internalMessage = parseInternalWebViewMessage(event.nativeEvent.data); + + if (internalMessage) { + handleInternalMessage(internalMessage); + return; + } + + handleChallengeMessage(event); + }; + return ( { - if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) { - injectVerifyData(); - return; - } - - 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); - if (journeyEnabled) { - clearJourneyEvents(); - } - } else /* error */ { - e.success = false; - } - onMessage(e); - }} + onMessage={handleWebViewMessage} javaScriptEnabled injectedJavaScript={patchPostMessageJsCode} automaticallyAdjustContentInsets style={[styles.webview, style]} source={{ - html: generateTheWebViewContent, + html: webViewContent, baseUrl: `${url}`, }} /> diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 1be5138..47ff8ea 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -5,12 +5,19 @@ import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-nati import Hcaptcha, { HCAPTCHA_READY_EVENT } from '../Hcaptcha'; import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking, peekJourneyEvents } from '../journey'; +import { reportApiLoadFailure, reportApiLoadTimeout } from '../loaderSentry'; +import { HCAPTCHA_LOADER_PREFIX } from '../webviewMessages'; import { getLastInjectJavaScriptMock, resetWebViewMockState, setWebViewMessageData, } from 'react-native-webview'; +jest.mock('../loaderSentry', () => ({ + reportApiLoadFailure: jest.fn(), + reportApiLoadTimeout: jest.fn(), +})); + const LONG_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001'; describe('Hcaptcha', () => { @@ -175,9 +182,17 @@ describe('Hcaptcha', () => { const renderMock = jest.fn(() => 'widget-id'); const executeMock = jest.fn(); const postMessageMock = jest.fn(); + const observerDisconnectMock = jest.fn(); + let observerCallback; const loaderCatchMock = jest.fn(); const loaderMock = jest.fn(() => ({ then: (callback) => { + observerCallback([{ + addedNodes: [{ + tagName: 'SCRIPT', + src: 'https://hcaptcha.com/1/api.js?render=explicit', + }], + }]); callback(); return { catch: loaderCatchMock }; }, @@ -190,12 +205,20 @@ describe('Hcaptcha', () => { }, document: { body: { style: {} }, + head: {}, }, hcaptcha: { execute: executeMock, render: renderMock, setData: jest.fn(), }, + MutationObserver: jest.fn((callback) => { + observerCallback = callback; + return { + disconnect: observerDisconnectMock, + observe: jest.fn(), + }; + }), window: null, }; @@ -210,7 +233,7 @@ describe('Hcaptcha', () => { vm.runInContext(runtimeScript, context); expect(getWebViewHtml(component)).toContain( - '' + '' ); expect(loaderMock).toHaveBeenCalledTimes(1); const loaderParams = loaderMock.mock.calls[0][0]; @@ -224,6 +247,13 @@ describe('Hcaptcha', () => { }); expect(new URLSearchParams(loaderParams.query).has('onload')).toBe(false); expect(typeof context.onloadCallback).toBe('function'); + expect(observerDisconnectMock).toHaveBeenCalledTimes(1); + expect(postMessageMock).toHaveBeenCalledWith(expect.stringContaining( + HCAPTCHA_LOADER_PREFIX + '{"type":"load-started","attempts":1' + )); + expect(postMessageMock).toHaveBeenCalledWith(expect.stringContaining( + HCAPTCHA_LOADER_PREFIX + '{"type":"load-succeeded","attempts":1' + )); expect(renderMock).toHaveBeenCalledWith('hcaptcha-container', expect.objectContaining({ sitekey: '00000000-0000-0000-0000-000000000000', @@ -246,6 +276,43 @@ describe('Hcaptcha', () => { expect(postMessageMock).toHaveBeenCalledWith('open'); }); + it('reports an internal loader failure when the pinned loader bundle is unavailable', () => { + const component = render( + + ); + const postMessageMock = jest.fn(); + const sandbox = { + console: { + log: jest.fn(), + warn: jest.fn(), + }, + window: null, + }; + + sandbox.window = sandbox; + sandbox.window.ReactNativeWebView = { postMessage: postMessageMock }; + + const context = vm.createContext(sandbox); + const [bootstrapScript, runtimeScript] = getInlineScripts(component); + + vm.runInContext(bootstrapScript, context); + vm.runInContext(runtimeScript, context); + + expect(postMessageMock).toHaveBeenNthCalledWith( + 1, + expect.stringContaining( + HCAPTCHA_LOADER_PREFIX + '{"type":"load-failed","attempts":0' + ) + ); + expect(postMessageMock.mock.calls[0][0]).toContain( + '"reason":"loader-unavailable"' + ); + expect(postMessageMock).toHaveBeenNthCalledWith(2, 'error'); + }); + it('serializes every HTML-facing prop safely before embedding it', () => { const theme = { palette: { @@ -379,6 +446,100 @@ describe('Hcaptcha', () => { }); }); + it('reports the existing loading timeout with loader context when Sentry is enabled', () => { + jest.useFakeTimers(); + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ + nativeEvent: { + data: HCAPTCHA_LOADER_PREFIX + JSON.stringify({ + type: 'load-started', + attempts: 2, + elapsedMs: 1000, + }), + }, + }); + jest.advanceTimersByTime(15000); + }); + + expect(reportApiLoadTimeout).toHaveBeenCalledWith({ + attempts: 2, + elapsedMs: 15000, + jsSrc: 'https://first-party.example/1/api.js', + siteKey: '00000000-0000-0000-0000-000000000000', + }); + }); + + it('reports terminal loader failures internally without exposing diagnostic messages', () => { + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ + nativeEvent: { + data: HCAPTCHA_LOADER_PREFIX + JSON.stringify({ + type: 'load-failed', + attempts: 3, + elapsedMs: 2400, + reason: 'script-error', + }), + }, + }); + }); + + expect(reportApiLoadFailure).toHaveBeenCalledWith({ + attempts: 3, + elapsedMs: 2400, + jsSrc: 'https://hcaptcha.com/1/api.js', + reason: 'script-error', + siteKey: '00000000-0000-0000-0000-000000000000', + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('treats widget readiness as loading completion for passive challenges', () => { + 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.toHaveBeenCalled(); + expect(reportApiLoadTimeout).not.toHaveBeenCalled(); + expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); + 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'); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 1890626..fcda792 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,66 +112,120 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() - - + + diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index 374bdcd..b2cdbb9 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -46,66 +46,120 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` - - + + diff --git a/__tests__/loaderSentry.test.js b/__tests__/loaderSentry.test.js new file mode 100644 index 0000000..41b24c7 --- /dev/null +++ b/__tests__/loaderSentry.test.js @@ -0,0 +1,87 @@ +import { captureExceptionMock, Scope, Sentry } from '@hcaptcha/sentry'; + +import { + reportApiLoadFailure, + reportApiLoadTimeout, +} from '../loaderSentry'; + +jest.mock('@hcaptcha/sentry', () => { + const captureException = jest.fn(); + + return { + captureExceptionMock: captureException, + Scope: jest.fn(() => ({ + setContext: jest.fn(), + setTag: jest.fn(), + setTags: jest.fn(), + })), + Sentry: jest.fn(() => ({ + captureException, + })), + }; +}); + +describe('loader Sentry reporting', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reports terminal loader failures with native context', () => { + reportApiLoadFailure({ + attempts: 3, + elapsedMs: 2500, + jsSrc: 'https://js.hcaptcha.com/1/api.js', + reason: 'loader-script-error', + siteKey: '00000000-0000-0000-0000-000000000000', + }); + + const scope = Scope.mock.results[0].value; + expect(Sentry).toHaveBeenCalledTimes(1); + expect(scope.setTags).toHaveBeenCalledWith(expect.objectContaining({ + api_loader_reason: 'loader-script-error', + sdk: '@hcaptcha/react-native-hcaptcha', + sdk_version: '4.0.0', + })); + expect(scope.setTag).toHaveBeenCalledWith( + 'sitekey', + '00000000-0000-0000-0000-000000000000' + ); + expect(scope.setContext).toHaveBeenCalledWith('api_loader', { + attempts: 3, + elapsed_ms: 2500, + js_src: 'https://js.hcaptcha.com/1/api.js', + reason: 'loader-script-error', + }); + expect(scope.setContext).toHaveBeenCalledWith('react_native', expect.objectContaining({ + version: expect.anything(), + })); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'hCaptcha loader failed to load api.js' }), + scope + ); + }); + + it('reports api.js loading timeouts with elapsed time', () => { + reportApiLoadTimeout({ + attempts: 1, + elapsedMs: 15000, + jsSrc: 'https://js.hcaptcha.com/1/api.js', + siteKey: '00000000-0000-0000-0000-000000000000', + }); + + const scope = Scope.mock.results[0].value; + expect(scope.setTags).toHaveBeenCalledWith(expect.objectContaining({ + api_loader_reason: 'timeout', + })); + expect(scope.setContext).toHaveBeenCalledWith('api_loader', { + attempts: 1, + elapsed_ms: 15000, + js_src: 'https://js.hcaptcha.com/1/api.js', + reason: 'timeout', + }); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'hCaptcha api.js loading timed out' }), + scope + ); + }); +}); diff --git a/__tests__/webviewMessages.test.js b/__tests__/webviewMessages.test.js new file mode 100644 index 0000000..0df20a3 --- /dev/null +++ b/__tests__/webviewMessages.test.js @@ -0,0 +1,38 @@ +import { + HCAPTCHA_LOADER_PREFIX, + HCAPTCHA_READY_EVENT, + parseInternalWebViewMessage, +} from '../webviewMessages'; + +describe('WebView internal messages', () => { + it('classifies widget readiness', () => { + expect(parseInternalWebViewMessage(HCAPTCHA_READY_EVENT)).toEqual({ + type: 'widget-ready', + }); + }); + + it('parses loader lifecycle events', () => { + expect(parseInternalWebViewMessage( + HCAPTCHA_LOADER_PREFIX + JSON.stringify({ + type: 'load-started', + attempts: 1, + elapsedMs: 25, + }) + )).toEqual({ + type: 'load-started', + attempts: 1, + elapsedMs: 25, + }); + }); + + it('classifies malformed prefixed messages as invalid internals', () => { + expect(parseInternalWebViewMessage( + HCAPTCHA_LOADER_PREFIX + '{invalid-json' + )).toEqual({ type: 'invalid' }); + }); + + it('leaves challenge messages for the public handler', () => { + expect(parseInternalWebViewMessage('open')).toBeNull(); + expect(parseInternalWebViewMessage('challenge-token')).toBeNull(); + }); +}); diff --git a/loaderSentry.js b/loaderSentry.js new file mode 100644 index 0000000..08e168b --- /dev/null +++ b/loaderSentry.js @@ -0,0 +1,95 @@ +import { Platform } from 'react-native'; + +import hcaptchaPackage from './package.json'; + +const SENTRY_DSN = 'https://d233059272824702afc8c43834c4912d@sentry.hcaptcha.com/6'; + +let loaderSentry; +let sentryModule; + +const getSentryModule = () => { + if (!sentryModule) { + // Keep the reporting dependency dormant while the public sentry option is disabled. + sentryModule = require('@hcaptcha/sentry'); + } + + return sentryModule; +}; + +const getLoaderSentry = () => { + if (!loaderSentry) { + const { Sentry } = getSentryModule(); + loaderSentry = new Sentry({ + dsn: SENTRY_DSN, + environment: 'production', + release: `react-native-hcaptcha@${hcaptchaPackage.version}`, + }); + } + + return loaderSentry; +}; + +const reportApiLoadIssue = ({ + attempts, + elapsedMs, + errorMessage, + jsSrc, + reason, + siteKey, +}) => { + try { + const { Scope } = getSentryModule(); + const scope = new Scope(); + scope.setTags({ + api_loader_reason: reason, + platform: Platform.OS, + sdk: '@hcaptcha/react-native-hcaptcha', + sdk_version: hcaptchaPackage.version, + }); + + if (siteKey) { + scope.setTag('sitekey', siteKey); + } + + scope.setContext('api_loader', { + attempts, + elapsed_ms: elapsedMs, + js_src: jsSrc, + reason, + }); + + scope.setContext('react_native', { + model: Platform.constants?.Model || Platform.constants?.model, + os_version: Platform.Version, + version: Platform.constants?.reactNativeVersion, + }); + + getLoaderSentry().captureException(new Error(errorMessage), scope); + } catch (_) { + // Diagnostics must never interfere with the challenge flow. + } +}; + +const reportApiLoadFailure = ({ attempts, elapsedMs, jsSrc, reason, siteKey }) => { + reportApiLoadIssue({ + attempts, + elapsedMs, + errorMessage: 'hCaptcha loader failed to load api.js', + jsSrc, + reason: reason || 'script-error', + siteKey, + }); +}; + +const reportApiLoadTimeout = ({ attempts, elapsedMs, jsSrc, siteKey }) => { + reportApiLoadIssue({ + attempts, + elapsedMs, + errorMessage: 'hCaptcha api.js loading timed out', + jsSrc, + reason: 'timeout', + siteKey, + }); +}; + +export { reportApiLoadFailure, reportApiLoadTimeout }; diff --git a/package-lock.json b/package-lock.json index 0d5e381..a491b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@hcaptcha/react-native-hcaptcha", "version": "4.0.0", "license": "MIT", + "dependencies": { + "@hcaptcha/sentry": "0.0.4" + }, "devDependencies": { "@react-native/babel-preset": "^0.78.0", "@react-native/eslint-config": "^0.78.0", @@ -2339,6 +2342,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@hcaptcha/sentry": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@hcaptcha/sentry/-/sentry-0.0.4.tgz", + "integrity": "sha512-xoY1hHnuc1UomatCPMMR5OXy4BmruDbR2U9IOiO0HiZIeDw6cKLDRZPwszh7NdU8N1kah5Iy3qGM1Zgeu3sfug==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/package.json b/package.json index 7991df3..f9b7ef4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "testPathIgnorePatterns": [ "/node_modules/", "/__e2e__/" + ], + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?|@hcaptcha/sentry)/)" ] }, "repository": { @@ -72,5 +75,8 @@ "react-native-webview": "*", "reassure": "^1.4.0" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "dependencies": { + "@hcaptcha/sentry": "0.0.4" + } } diff --git a/webviewContent.js b/webviewContent.js new file mode 100644 index 0000000..1e9001c --- /dev/null +++ b/webviewContent.js @@ -0,0 +1,217 @@ +const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@2.3.0/dist/index.es5.js'; + +const generateWebViewContent = ({ + loaderMessagePrefix, + readyEvent, + serializedConfig, +}) => ` + + + + + + + + + + +
+ + `; + +export { generateWebViewContent }; diff --git a/webviewMessages.js b/webviewMessages.js new file mode 100644 index 0000000..db65054 --- /dev/null +++ b/webviewMessages.js @@ -0,0 +1,39 @@ +const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; +const HCAPTCHA_LOADER_PREFIX = '__hcaptcha_loader__:'; + +const parseInternalWebViewMessage = (message) => { + if (message === HCAPTCHA_READY_EVENT) { + return { type: 'widget-ready' }; + } + + if ( + typeof message !== 'string' + || !message.startsWith(HCAPTCHA_LOADER_PREFIX) + ) { + return null; + } + + try { + const loaderEvent = JSON.parse( + message.slice(HCAPTCHA_LOADER_PREFIX.length) + ); + + if ( + !loaderEvent + || typeof loaderEvent !== 'object' + || typeof loaderEvent.type !== 'string' + ) { + return { type: 'invalid' }; + } + + return loaderEvent; + } catch (_) { + return { type: 'invalid' }; + } +}; + +export { + HCAPTCHA_LOADER_PREFIX, + HCAPTCHA_READY_EVENT, + parseInternalWebViewMessage, +}; From 247f9adc5ee119f2845e9bed929ef34ca91b049c Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Mon, 13 Jul 2026 10:07:06 -0300 Subject: [PATCH 03/13] Minimize loader diagnostics changes --- Hcaptcha.js | 298 ++++++++++++++++-- __tests__/Hcaptcha.test.js | 43 ++- .../ConfirmHcaptcha.test.js.snap | 23 +- __tests__/__snapshots__/Hcaptcha.test.js.snap | 21 +- __tests__/loaderSentry.test.js | 87 ----- __tests__/webviewMessages.test.js | 38 --- loaderSentry.js | 95 ------ package.json | 3 - webviewContent.js | 217 ------------- webviewMessages.js | 39 --- 10 files changed, 312 insertions(+), 552 deletions(-) delete mode 100644 __tests__/loaderSentry.test.js delete mode 100644 __tests__/webviewMessages.test.js delete mode 100644 loaderSentry.js delete mode 100644 webviewContent.js delete mode 100644 webviewMessages.js diff --git a/Hcaptcha.js b/Hcaptcha.js index 9804171..c433d75 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -5,16 +5,6 @@ import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; import md5 from './md5'; import hcaptchaPackage from './package.json'; -import { - reportApiLoadFailure, - reportApiLoadTimeout, -} from './loaderSentry'; -import { generateWebViewContent } from './webviewContent'; -import { - HCAPTCHA_LOADER_PREFIX, - HCAPTCHA_READY_EVENT, - parseInternalWebViewMessage, -} from './webviewMessages'; import { clearJourneyEvents, disableJourneyConsumer, @@ -36,6 +26,85 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; +const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; +const HCAPTCHA_LOADER_PREFIX = '__hcaptcha_loader__:'; +const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@2.3.0/dist/index.es5.js'; +const SENTRY_DSN = 'https://d233059272824702afc8c43834c4912d@sentry.hcaptcha.com/6'; + +let loaderSentry; +let sentryModule; + +const reportApiLoadIssue = ({ + attempts, + elapsedMs, + errorMessage, + jsSrc, + reason, + siteKey, +}) => { + try { + if (!sentryModule) { + sentryModule = require('@hcaptcha/sentry'); + } + + const { Scope, Sentry } = sentryModule; + const scope = new Scope(); + scope.setTags({ + api_loader_reason: reason, + platform: Platform.OS, + sdk: '@hcaptcha/react-native-hcaptcha', + sdk_version: hcaptchaPackage.version, + }); + + if (siteKey) { + scope.setTag('sitekey', siteKey); + } + + scope.setContext('api_loader', { + attempts, + elapsed_ms: elapsedMs, + js_src: jsSrc, + reason, + }); + scope.setContext('react_native', { + model: Platform.constants?.Model || Platform.constants?.model, + os_version: Platform.Version, + version: Platform.constants?.reactNativeVersion, + }); + + if (!loaderSentry) { + loaderSentry = new Sentry({ + dsn: SENTRY_DSN, + environment: 'production', + release: `react-native-hcaptcha@${hcaptchaPackage.version}`, + }); + } + + loaderSentry.captureException(new Error(errorMessage), scope); + } catch (_) { + // Diagnostics must never interfere with the challenge flow. + } +}; + +const parseInternalWebViewMessage = (message) => { + if (message === HCAPTCHA_READY_EVENT) { + return { type: 'widget-ready' }; + } + + if (typeof message !== 'string' || !message.startsWith(HCAPTCHA_LOADER_PREFIX)) { + return null; + } + + try { + const loaderEvent = JSON.parse(message.slice(HCAPTCHA_LOADER_PREFIX.length)); + return loaderEvent && typeof loaderEvent.type === 'string' + ? loaderEvent + : { type: 'invalid' }; + } catch (_) { + return { type: 'invalid' }; + } +}; + const serializeForInlineScript = (value) => JSON.stringify(value) .replace(/ generateWebViewContent({ - loaderMessagePrefix: HCAPTCHA_LOADER_PREFIX, - readyEvent: HCAPTCHA_READY_EVENT, - serializedConfig: serializedWebViewConfig, - }), + () => ` + + + + + + + + + + +
+ + `, [serializedWebViewConfig] ); @@ -288,10 +543,12 @@ const Hcaptcha = ({ } if (sentry && !apiLoadFailureReportedRef.current) { - reportApiLoadTimeout({ + reportApiLoadIssue({ attempts: apiLoadAttemptsRef.current, elapsedMs: loadingTimeout, + errorMessage: 'hCaptcha api.js loading timed out', jsSrc: apiScriptSource, + reason: 'timeout', siteKey, }); } @@ -349,11 +606,12 @@ const Hcaptcha = ({ apiLoadAttemptsRef.current = message.attempts; if (sentry && !apiLoadFailureReportedRef.current) { - reportApiLoadFailure({ + reportApiLoadIssue({ attempts: message.attempts, elapsedMs: message.elapsedMs, + errorMessage: 'hCaptcha loader failed to load api.js', jsSrc: apiScriptSource, - reason: message.reason, + reason: message.reason || 'script-error', siteKey, }); apiLoadFailureReportedRef.current = true; diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 47ff8ea..c2be507 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -5,17 +5,25 @@ import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-nati import Hcaptcha, { HCAPTCHA_READY_EVENT } from '../Hcaptcha'; import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking, peekJourneyEvents } from '../journey'; -import { reportApiLoadFailure, reportApiLoadTimeout } from '../loaderSentry'; -import { HCAPTCHA_LOADER_PREFIX } from '../webviewMessages'; import { getLastInjectJavaScriptMock, resetWebViewMockState, setWebViewMessageData, } from 'react-native-webview'; -jest.mock('../loaderSentry', () => ({ - reportApiLoadFailure: jest.fn(), - reportApiLoadTimeout: jest.fn(), +const HCAPTCHA_LOADER_PREFIX = '__hcaptcha_loader__:'; +const mockCaptureException = jest.fn(); +const mockSetContext = jest.fn(); + +jest.mock('@hcaptcha/sentry', () => ({ + Scope: jest.fn(() => ({ + setContext: mockSetContext, + setTag: jest.fn(), + setTags: jest.fn(), + })), + Sentry: jest.fn(() => ({ + captureException: mockCaptureException, + })), })); const LONG_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001'; @@ -472,12 +480,16 @@ describe('Hcaptcha', () => { jest.advanceTimersByTime(15000); }); - expect(reportApiLoadTimeout).toHaveBeenCalledWith({ + expect(mockSetContext).toHaveBeenCalledWith('api_loader', { attempts: 2, - elapsedMs: 15000, - jsSrc: 'https://first-party.example/1/api.js', - siteKey: '00000000-0000-0000-0000-000000000000', + elapsed_ms: 15000, + js_src: 'https://first-party.example/1/api.js', + reason: 'timeout', }); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'hCaptcha api.js loading timed out' }), + expect.anything() + ); }); it('reports terminal loader failures internally without exposing diagnostic messages', () => { @@ -504,13 +516,16 @@ describe('Hcaptcha', () => { }); }); - expect(reportApiLoadFailure).toHaveBeenCalledWith({ + expect(mockSetContext).toHaveBeenCalledWith('api_loader', { attempts: 3, - elapsedMs: 2400, - jsSrc: 'https://hcaptcha.com/1/api.js', + elapsed_ms: 2400, + js_src: 'https://hcaptcha.com/1/api.js', reason: 'script-error', - siteKey: '00000000-0000-0000-0000-000000000000', }); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'hCaptcha loader failed to load api.js' }), + expect.anything() + ); expect(onMessage).not.toHaveBeenCalled(); }); @@ -535,7 +550,7 @@ describe('Hcaptcha', () => { }); expect(onMessage).not.toHaveBeenCalled(); - expect(reportApiLoadTimeout).not.toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); }); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index fcda792..ea940f0 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,13 +112,11 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() - - - - -
- - `; - -export { generateWebViewContent }; diff --git a/webviewMessages.js b/webviewMessages.js deleted file mode 100644 index db65054..0000000 --- a/webviewMessages.js +++ /dev/null @@ -1,39 +0,0 @@ -const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; -const HCAPTCHA_LOADER_PREFIX = '__hcaptcha_loader__:'; - -const parseInternalWebViewMessage = (message) => { - if (message === HCAPTCHA_READY_EVENT) { - return { type: 'widget-ready' }; - } - - if ( - typeof message !== 'string' - || !message.startsWith(HCAPTCHA_LOADER_PREFIX) - ) { - return null; - } - - try { - const loaderEvent = JSON.parse( - message.slice(HCAPTCHA_LOADER_PREFIX.length) - ); - - if ( - !loaderEvent - || typeof loaderEvent !== 'object' - || typeof loaderEvent.type !== 'string' - ) { - return { type: 'invalid' }; - } - - return loaderEvent; - } catch (_) { - return { type: 'invalid' }; - } -}; - -export { - HCAPTCHA_LOADER_PREFIX, - HCAPTCHA_READY_EVENT, - parseInternalWebViewMessage, -}; From 38a3addba526b7b3f47a5d6ecb42c29b9fd37d82 Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Mon, 13 Jul 2026 10:23:48 -0300 Subject: [PATCH 04/13] Remove loader lifecycle bridge --- Hcaptcha.js | 187 ++---------------- __tests__/Hcaptcha.test.js | 92 +-------- .../ConfirmHcaptcha.test.js.snap | 83 +------- __tests__/__snapshots__/Hcaptcha.test.js.snap | 83 +------- 4 files changed, 31 insertions(+), 414 deletions(-) diff --git a/Hcaptcha.js b/Hcaptcha.js index c433d75..13cc1e0 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -27,49 +27,29 @@ const patchPostMessageJsCode = `(${String(function () { })})();`; const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; -const HCAPTCHA_LOADER_PREFIX = '__hcaptcha_loader__:'; const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@2.3.0/dist/index.es5.js'; const SENTRY_DSN = 'https://d233059272824702afc8c43834c4912d@sentry.hcaptcha.com/6'; let loaderSentry; -let sentryModule; -const reportApiLoadIssue = ({ - attempts, - elapsedMs, - errorMessage, - jsSrc, - reason, - siteKey, -}) => { +const reportApiLoadTimeout = ({ elapsedMs, jsSrc, siteKey }) => { try { - if (!sentryModule) { - sentryModule = require('@hcaptcha/sentry'); - } - - const { Scope, Sentry } = sentryModule; + const { Scope, Sentry } = require('@hcaptcha/sentry'); const scope = new Scope(); - scope.setTags({ - api_loader_reason: reason, - platform: Platform.OS, - sdk: '@hcaptcha/react-native-hcaptcha', - sdk_version: hcaptchaPackage.version, - }); + scope.setTag('api_loader_reason', 'timeout'); if (siteKey) { scope.setTag('sitekey', siteKey); } scope.setContext('api_loader', { - attempts, elapsed_ms: elapsedMs, js_src: jsSrc, - reason, - }); - scope.setContext('react_native', { + reason: 'timeout', + platform: Platform.OS, model: Platform.constants?.Model || Platform.constants?.model, os_version: Platform.Version, - version: Platform.constants?.reactNativeVersion, + react_native_version: Platform.constants?.reactNativeVersion, }); if (!loaderSentry) { @@ -80,31 +60,12 @@ const reportApiLoadIssue = ({ }); } - loaderSentry.captureException(new Error(errorMessage), scope); + loaderSentry.captureException(new Error('hCaptcha api.js loading timed out'), scope); } catch (_) { // Diagnostics must never interfere with the challenge flow. } }; -const parseInternalWebViewMessage = (message) => { - if (message === HCAPTCHA_READY_EVENT) { - return { type: 'widget-ready' }; - } - - if (typeof message !== 'string' || !message.startsWith(HCAPTCHA_LOADER_PREFIX)) { - return null; - } - - try { - const loaderEvent = JSON.parse(message.slice(HCAPTCHA_LOADER_PREFIX.length)); - return loaderEvent && typeof loaderEvent.type === 'string' - ? loaderEvent - : { type: 'invalid' }; - } catch (_) { - return { type: 'invalid' }; - } -}; - const serializeForInlineScript = (value) => JSON.stringify(value) .replace(/ - + ' + '' ); expect(loaderMock).toHaveBeenCalledTimes(1); const loaderParams = loaderMock.mock.calls[0][0]; @@ -255,13 +238,6 @@ describe('Hcaptcha', () => { }); expect(new URLSearchParams(loaderParams.query).has('onload')).toBe(false); expect(typeof context.onloadCallback).toBe('function'); - expect(observerDisconnectMock).toHaveBeenCalledTimes(1); - expect(postMessageMock).toHaveBeenCalledWith(expect.stringContaining( - HCAPTCHA_LOADER_PREFIX + '{"type":"load-started","attempts":1' - )); - expect(postMessageMock).toHaveBeenCalledWith(expect.stringContaining( - HCAPTCHA_LOADER_PREFIX + '{"type":"load-succeeded","attempts":1' - )); expect(renderMock).toHaveBeenCalledWith('hcaptcha-container', expect.objectContaining({ sitekey: '00000000-0000-0000-0000-000000000000', @@ -284,7 +260,7 @@ describe('Hcaptcha', () => { expect(postMessageMock).toHaveBeenCalledWith('open'); }); - it('reports an internal loader failure when the pinned loader bundle is unavailable', () => { + it('reports an error when the pinned loader bundle is unavailable', () => { const component = render( { vm.runInContext(bootstrapScript, context); vm.runInContext(runtimeScript, context); - expect(postMessageMock).toHaveBeenNthCalledWith( - 1, - expect.stringContaining( - HCAPTCHA_LOADER_PREFIX + '{"type":"load-failed","attempts":0' - ) - ); - expect(postMessageMock.mock.calls[0][0]).toContain( - '"reason":"loader-unavailable"' - ); - expect(postMessageMock).toHaveBeenNthCalledWith(2, 'error'); + expect(postMessageMock).toHaveBeenCalledWith('error'); }); it('serializes every HTML-facing prop safely before embedding it', () => { @@ -457,7 +424,7 @@ describe('Hcaptcha', () => { it('reports the existing loading timeout with loader context when Sentry is enabled', () => { jest.useFakeTimers(); const onMessage = jest.fn(); - const component = render( + render( { ); act(() => { - getWebView(component).props.onMessage({ - nativeEvent: { - data: HCAPTCHA_LOADER_PREFIX + JSON.stringify({ - type: 'load-started', - attempts: 2, - elapsedMs: 1000, - }), - }, - }); jest.advanceTimersByTime(15000); }); - expect(mockSetContext).toHaveBeenCalledWith('api_loader', { - attempts: 2, + expect(mockSetContext).toHaveBeenCalledWith('api_loader', expect.objectContaining({ elapsed_ms: 15000, js_src: 'https://first-party.example/1/api.js', reason: 'timeout', - }); + })); expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: 'hCaptcha api.js loading timed out' }), expect.anything() ); }); - it('reports terminal loader failures internally without exposing diagnostic messages', () => { - const onMessage = jest.fn(); - const component = render( - - ); - - act(() => { - getWebView(component).props.onMessage({ - nativeEvent: { - data: HCAPTCHA_LOADER_PREFIX + JSON.stringify({ - type: 'load-failed', - attempts: 3, - elapsedMs: 2400, - reason: 'script-error', - }), - }, - }); - }); - - expect(mockSetContext).toHaveBeenCalledWith('api_loader', { - attempts: 3, - elapsed_ms: 2400, - js_src: 'https://hcaptcha.com/1/api.js', - reason: 'script-error', - }); - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ message: 'hCaptcha loader failed to load api.js' }), - expect.anything() - ); - expect(onMessage).not.toHaveBeenCalled(); - }); - it('treats widget readiness as loading completion for passive challenges', () => { jest.useFakeTimers(); const onMessage = jest.fn(); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index ea940f0..afe07f4 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,48 +112,12 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() - + - + @@ -423,24 +399,22 @@ const Hcaptcha = ({ useEffect(() => { const timeoutId = setTimeout(() => { - if (!isLoadingRef.current) { - return; - } - - if (sentry) { - reportApiLoadTimeout({ - elapsedMs: loadingTimeout, - jsSrc: apiScriptSource, - siteKey, - }); + if (isLoadingRef.current) { + if (sentry) { + reportApiLoadTimeout({ + elapsedMs: loadingTimeout, + jsSrc: jsSrc || 'https://hcaptcha.com/1/api.js', + siteKey, + }); + } + onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); } - - onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); }, loadingTimeout); return () => clearTimeout(timeoutId); - }, [apiScriptSource, onMessage, sentry, siteKey]); + }, [jsSrc, onMessage, sentry, siteKey]); + const webViewRef = useRef(null); const injectVerifyData = (resetFirst = false) => { if (!webViewRef.current) { return; @@ -468,43 +442,6 @@ const Hcaptcha = ({ injectVerifyData(true); }; - const markLoadingComplete = () => { - if (!isLoadingRef.current) { - return; - } - - isLoadingRef.current = false; - setIsLoading(false); - }; - - const handleChallengeMessage = (event) => { - markLoadingComplete(); - event.reset = reset; - event.success = true; - - if (event.nativeEvent.data.length > 35) { - const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); - event.markUsed = () => clearTimeout(expiredTokenTimerId); - if (journeyEnabled) { - clearJourneyEvents(); - } - } else if (event.nativeEvent.data !== 'open') { - event.success = false; - } - - onMessage(event); - }; - - const handleWebViewMessage = (event) => { - if (event.nativeEvent.data === HCAPTCHA_READY_EVENT) { - markLoadingComplete(); - injectVerifyData(); - return; - } - - handleChallengeMessage(event); - }; - return ( { + if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) { + isLoadingRef.current = false; + setIsLoading(false); + injectVerifyData(); + return; + } + + e.reset = reset; + e.success = true; + if (e.nativeEvent.data === 'open') { + isLoadingRef.current = false; + setIsLoading(false); + } else if (e.nativeEvent.data.length > 35) { + const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); + e.markUsed = () => clearTimeout(expiredTokenTimerId); + if (journeyEnabled) { + clearJourneyEvents(); + } + } else /* error */ { + e.success = false; + } + onMessage(e); + }} javaScriptEnabled injectedJavaScript={patchPostMessageJsCode} automaticallyAdjustContentInsets style={[styles.webview, style]} source={{ - html: webViewContent, + html: generateTheWebViewContent, baseUrl: `${url}`, }} /> diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index afe07f4..44433cd 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -113,69 +113,70 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index d3683d6..703bf15 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -47,69 +47,70 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` From ac0b89fbb42bf7075d02e8ee8acdef64a999de20 Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Mon, 13 Jul 2026 10:42:37 -0300 Subject: [PATCH 06/13] Remove native loader timeout reporting --- Hcaptcha.js | 46 +------------------------------------- __tests__/Hcaptcha.test.js | 43 ----------------------------------- package-lock.json | 9 -------- package.json | 5 +---- 4 files changed, 2 insertions(+), 101 deletions(-) diff --git a/Hcaptcha.js b/Hcaptcha.js index 5c0b99d..8fae862 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -28,43 +28,6 @@ const patchPostMessageJsCode = `(${String(function () { const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@2.3.0/dist/index.es5.js'; -const SENTRY_DSN = 'https://d233059272824702afc8c43834c4912d@sentry.hcaptcha.com/6'; - -let loaderSentry; - -const reportApiLoadTimeout = ({ elapsedMs, jsSrc, siteKey }) => { - try { - const { Scope, Sentry } = require('@hcaptcha/sentry'); - const scope = new Scope(); - scope.setTag('api_loader_reason', 'timeout'); - - if (siteKey) { - scope.setTag('sitekey', siteKey); - } - - scope.setContext('api_loader', { - elapsed_ms: elapsedMs, - js_src: jsSrc, - reason: 'timeout', - platform: Platform.OS, - model: Platform.constants?.Model || Platform.constants?.model, - os_version: Platform.Version, - react_native_version: Platform.constants?.reactNativeVersion, - }); - - if (!loaderSentry) { - loaderSentry = new Sentry({ - dsn: SENTRY_DSN, - environment: 'production', - release: `react-native-hcaptcha@${hcaptchaPackage.version}`, - }); - } - - loaderSentry.captureException(new Error('hCaptcha api.js loading timed out'), scope); - } catch (_) { - // Diagnostics must never interfere with the challenge flow. - } -}; const serializeForInlineScript = (value) => JSON.stringify(value) @@ -400,19 +363,12 @@ const Hcaptcha = ({ useEffect(() => { const timeoutId = setTimeout(() => { if (isLoadingRef.current) { - if (sentry) { - reportApiLoadTimeout({ - elapsedMs: loadingTimeout, - jsSrc: jsSrc || 'https://hcaptcha.com/1/api.js', - siteKey, - }); - } onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); } }, loadingTimeout); return () => clearTimeout(timeoutId); - }, [jsSrc, onMessage, sentry, siteKey]); + }, [onMessage]); const webViewRef = useRef(null); const injectVerifyData = (resetFirst = false) => { diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 0c9bead..fffe43c 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -11,20 +11,6 @@ import { setWebViewMessageData, } from 'react-native-webview'; -const mockCaptureException = jest.fn(); -const mockSetContext = jest.fn(); - -jest.mock('@hcaptcha/sentry', () => ({ - Scope: jest.fn(() => ({ - setContext: mockSetContext, - setTag: jest.fn(), - setTags: jest.fn(), - })), - Sentry: jest.fn(() => ({ - captureException: mockCaptureException, - })), -})); - const LONG_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001'; describe('Hcaptcha', () => { @@ -421,34 +407,6 @@ describe('Hcaptcha', () => { }); }); - it('reports the existing loading timeout with loader context when Sentry is enabled', () => { - jest.useFakeTimers(); - const onMessage = jest.fn(); - render( - - ); - - act(() => { - jest.advanceTimersByTime(15000); - }); - - expect(mockSetContext).toHaveBeenCalledWith('api_loader', expect.objectContaining({ - elapsed_ms: 15000, - js_src: 'https://first-party.example/1/api.js', - reason: 'timeout', - })); - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ message: 'hCaptcha api.js loading timed out' }), - expect.anything() - ); - }); - it('treats widget readiness as loading completion for passive challenges', () => { jest.useFakeTimers(); const onMessage = jest.fn(); @@ -470,7 +428,6 @@ describe('Hcaptcha', () => { }); expect(onMessage).not.toHaveBeenCalled(); - expect(mockCaptureException).not.toHaveBeenCalled(); expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); }); diff --git a/package-lock.json b/package-lock.json index a491b51..0d5e381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "@hcaptcha/react-native-hcaptcha", "version": "4.0.0", "license": "MIT", - "dependencies": { - "@hcaptcha/sentry": "0.0.4" - }, "devDependencies": { "@react-native/babel-preset": "^0.78.0", "@react-native/eslint-config": "^0.78.0", @@ -2342,12 +2339,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@hcaptcha/sentry": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@hcaptcha/sentry/-/sentry-0.0.4.tgz", - "integrity": "sha512-xoY1hHnuc1UomatCPMMR5OXy4BmruDbR2U9IOiO0HiZIeDw6cKLDRZPwszh7NdU8N1kah5Iy3qGM1Zgeu3sfug==", - "license": "MIT" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/package.json b/package.json index 3f22721..7991df3 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,5 @@ "react-native-webview": "*", "reassure": "^1.4.0" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", - "dependencies": { - "@hcaptcha/sentry": "0.0.4" - } + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } From 9a39ef20459031c54400fdff49597d943b635782 Mon Sep 17 00:00:00 2001 From: Damian Nunez Rodriguez Date: Mon, 13 Jul 2026 17:15:48 -0300 Subject: [PATCH 07/13] Bundle hCaptcha Loader with the SDK --- Hcaptcha.js | 10 ++++++---- __mocks__/global.js | 4 ++++ __tests__/Hcaptcha.test.js | 9 ++++----- __tests__/__snapshots__/ConfirmHcaptcha.test.js.snap | 8 +++++--- __tests__/__snapshots__/Hcaptcha.test.js.snap | 8 +++++--- package.json | 3 +++ 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/Hcaptcha.js b/Hcaptcha.js index 8fae862..1169d64 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'; @@ -27,7 +28,6 @@ const patchPostMessageJsCode = `(${String(function () { })})();`; const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; -const HCAPTCHA_LOADER_URL = 'https://unpkg.com/@hcaptcha/loader@2.3.0/dist/index.es5.js'; const serializeForInlineScript = (value) => JSON.stringify(value) @@ -234,7 +234,7 @@ const Hcaptcha = ({ apiUrl, backgroundColor: backgroundColor ?? '', debugInfo, - loaderSentry: Boolean(sentry), + sentry: Boolean(sentry), phoneNumber: phoneNumber ?? null, phonePrefix: phonePrefix ?? null, rqdata: rqdata ?? null, @@ -257,7 +257,9 @@ const Hcaptcha = ({ var hcaptchaConfig = ${serializedWebViewConfig}; Object.entries(hcaptchaConfig.debugInfo || {}).forEach(function (entry) { window[entry[0]] = entry[1] }); - + ' - ); + expect(loaderScript).toContain('window.__hcaptcha_loader_inline__ = true;'); + expect(getWebViewHtml(component)).not.toContain('unpkg.com'); expect(loaderMock).toHaveBeenCalledTimes(1); const loaderParams = loaderMock.mock.calls[0][0]; expect(loaderParams.scriptSource).toBe('https://hcaptcha.com/1/api.js'); @@ -266,7 +265,7 @@ describe('Hcaptcha', () => { sandbox.window.ReactNativeWebView = { postMessage: postMessageMock }; const context = vm.createContext(sandbox); - const [bootstrapScript, runtimeScript] = getInlineScripts(component); + const [bootstrapScript, , runtimeScript] = getInlineScripts(component); vm.runInContext(bootstrapScript, context); vm.runInContext(runtimeScript, context); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 44433cd..2ec1090 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,10 +112,12 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() - + - + - -