From ebcdaa0eaf22c35ba5c6419ae884e89050f334a7 Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 26 Mar 2026 09:32:20 -0400 Subject: [PATCH 01/13] feat(journeys): add core runtime, init hook, and setData bridge Phase 1 implements the journey runtime and the first shippable automatic capture path. Scope of work: - add a shared journey runtime with bounded buffering, navigation registration, and consumer lifecycle gating - install automatic app wrapping through AppRegistry.setWrapperComponentProvider using an internal wrapper component - capture initial automatic touch events (tap and basic drag) from the wrapper and screen transitions from a registered navigation container - migrate the captcha bridge from execute(opts) payload injection to Android-aligned setData(widgetId, payload) followed by execute(widgetId) - add verifyParams support with precedence over deprecated rqdata/phone fields - wire ConfirmHcaptcha and Hcaptcha into journey consumer lifecycle management - add focused runtime and captcha integration tests and update affected snapshots Review notes applied before commit: - removed render-time mutation by switching payload reads to peek semantics - fixed re-entry behavior so the current screen is emitted again when collection restarts on the same route - kept the public surface limited to init/register plus verifyParams and userJourney for this phase --- Hcaptcha.d.ts | 16 +- Hcaptcha.js | 122 ++++++++-- __tests__/Hcaptcha.test.js | 67 ++++- .../ConfirmHcaptcha.test.js.snap | 34 ++- __tests__/__snapshots__/Hcaptcha.test.js.snap | 34 ++- __tests__/journey.test.js | 157 ++++++++++++ index.d.ts | 14 +- index.js | 41 ++++ journey/index.js | 14 ++ journey/runtime.js | 229 ++++++++++++++++++ journey/schema.js | 47 ++++ journey/wrapper.js | 116 +++++++++ 12 files changed, 812 insertions(+), 79 deletions(-) create mode 100644 __tests__/journey.test.js create mode 100644 journey/index.js create mode 100644 journey/runtime.js create mode 100644 journey/schema.js create mode 100644 journey/wrapper.js diff --git a/Hcaptcha.d.ts b/Hcaptcha.d.ts index d29298d..5a75b8d 100644 --- a/Hcaptcha.d.ts +++ b/Hcaptcha.d.ts @@ -2,7 +2,13 @@ import React from 'react'; import { StyleProp, ViewStyle } from 'react-native'; import { WebViewMessageEvent } from 'react-native-webview'; -type HcaptchaProps = { +export type HCaptchaVerifyParams = { + rqdata?: string; + phonePrefix?: string; + phoneNumber?: string; +}; + +export type HcaptchaProps = { /** * The callback function that runs after receiving a response, error, or when user cancels. */ @@ -52,6 +58,10 @@ type HcaptchaProps = { * Hcaptcha execution options (see Enterprise docs) */ rqdata?: string; + /** + * Verification payload overrides. Values here take precedence over deprecated top-level fields. + */ + verifyParams?: HCaptchaVerifyParams; /** * Enable / Disable sentry error reporting. */ @@ -99,6 +109,10 @@ type HcaptchaProps = { * Optional full phone number in E.164 format ("+44123..."), for use in MFA. */ phoneNumber?: string; + /** + * Enable automatic user journey injection. + */ + userJourney?: boolean; } interface CustomWebViewMessageEvent extends WebViewMessageEvent { diff --git a/Hcaptcha.js b/Hcaptcha.js index dc5615e..cd99b2b 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -5,6 +5,12 @@ import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; import md5 from './md5'; import hcaptchaPackage from './package.json'; +import { + clearJourneyEvents, + disableJourneyConsumer, + enableJourneyConsumer, + peekJourneyEvents, +} from './journey'; const patchPostMessageJsCode = `(${String(function () { var originalPostMessage = window.ReactNativeWebView.postMessage; @@ -56,6 +62,38 @@ const normalizeSize = (value) => { return value === 'checkbox' ? 'normal' : value; }; +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) => + `try { 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`; @@ -100,6 +138,8 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, * @param {string} orientation: hCaptcha challenge orientation * @param {string} phonePrefix: Optional phone country calling code (without '+'), e.g., "44". Used in MFA flows. * @param {string} phoneNumber: Optional full phone number in E.164 format ("+44123..."), for use in MFA. + * @param {boolean} userJourney: Enable automatic user journey injection + * @param {object} verifyParams: Verification payload overrides */ const Hcaptcha = ({ onMessage, @@ -125,10 +165,15 @@ const Hcaptcha = ({ orientation, phonePrefix, phoneNumber, + userJourney, + verifyParams, + _journeyManagedExternally, }) => { const tokenTimeout = 120000; const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); + const journeyEnabled = Boolean(userJourney); + const hasJourneyConsumerRef = useRef(false); const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]); const normalizedSize = useMemo(() => normalizeSize(size), [size]); const apiUrl = useMemo( @@ -153,19 +198,28 @@ const Hcaptcha = ({ [debug] ); + const verifyData = useMemo( + () => buildVerifyData({ + phoneNumber, + phonePrefix, + rqdata, + userJourney: journeyEnabled ? peekJourneyEvents() : undefined, + verifyParams, + }), + [journeyEnabled, phoneNumber, phonePrefix, rqdata, verifyParams] + ); + const serializedWebViewConfig = useMemo( () => serializeForInlineScript({ apiUrl, backgroundColor: backgroundColor ?? '', debugInfo, - phoneNumber: phoneNumber ?? null, - phonePrefix: phonePrefix ?? null, - rqdata: rqdata ?? null, siteKey: siteKey || '', size: normalizedSize, theme: normalizedTheme, + verifyData, }), - [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, siteKey] + [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, siteKey, verifyData] ); const generateTheWebViewContent = useMemo( @@ -188,10 +242,20 @@ const Hcaptcha = ({ script.src = hcaptchaConfig.apiUrl; document.head.appendChild(script); }; + var hcaptchaWidgetId = null; + var setData = function(data) { + hcaptcha.setData(hcaptchaWidgetId, data || {}); + }; + var execute = function() { + hcaptcha.execute(hcaptchaWidgetId); + }; + var reset = function() { + hcaptcha.reset(hcaptchaWidgetId); + }; var onloadCallback = function() { try { console.log("challenge onload starting"); - hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { @@ -200,7 +264,8 @@ const Hcaptcha = ({ } try { console.log("showing challenge"); - hcaptcha.execute(getExecuteOpts()); + setData(hcaptchaConfig.verifyData || {}); + execute(); } catch (e) { console.log("failed to show challenge:", e); window.ReactNativeWebView.postMessage(e.name); @@ -239,23 +304,6 @@ const Hcaptcha = ({ } return config; }; - const getExecuteOpts = function() { - var opts = {}; - const rqdata = hcaptchaConfig.rqdata; - const phonePrefix = hcaptchaConfig.phonePrefix; - const phoneNumber = hcaptchaConfig.phoneNumber; - - if (rqdata) { - opts.rqdata = rqdata; - } - if (phonePrefix) { - opts.mfa_phoneprefix = phonePrefix; - } - if (phoneNumber) { - opts.mfa_phone = phoneNumber; - } - return opts; - }; loadApiScript(); @@ -266,6 +314,22 @@ const Hcaptcha = ({ [serializedWebViewConfig] ); + 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 (isLoading) { @@ -289,7 +353,13 @@ const Hcaptcha = ({ const reset = () => { if (webViewRef.current) { - webViewRef.current.injectJavaScript('onloadCallback();'); + webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({ + phoneNumber, + phonePrefix, + rqdata, + userJourney: journeyEnabled ? peekJourneyEvents() : undefined, + verifyParams, + }))); } }; @@ -326,6 +396,9 @@ const Hcaptcha = ({ } 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; } @@ -360,3 +433,4 @@ const styles = StyleSheet.create({ }); export default Hcaptcha; +export { buildVerifyData }; diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 1954533..1454fb2 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -4,6 +4,7 @@ import { act, render, waitFor } from '@testing-library/react-native'; import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-native'; import Hcaptcha from '../Hcaptcha'; +import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking } from '../journey'; import { getLastInjectJavaScriptMock, resetWebViewMockState, @@ -33,6 +34,7 @@ describe('Hcaptcha', () => { jest.clearAllMocks(); jest.useRealTimers(); resetWebViewMockState(); + __unsafeResetJourneyRuntime(); }); it('renders Hcaptcha with minimum props', () => { @@ -94,9 +96,11 @@ describe('Hcaptcha', () => { expect(config.size).toBe('normal'); expect(config.backgroundColor).toBe('rgba(0.1, 0.1, 0.1, 0.4)'); expect(config.theme).toBe('contrast'); - expect(config.rqdata).toBe('{"some":"data"}'); - expect(config.phonePrefix).toBe('44'); - expect(config.phoneNumber).toBe('+441234567890'); + expect(config.verifyData).toEqual({ + rqdata: '{"some":"data"}', + mfa_phoneprefix: '44', + mfa_phone: '+441234567890', + }); expect(config.debugInfo).toMatchObject({ customDebug: 'enabled', rnver_0_0_0: true, @@ -190,8 +194,9 @@ describe('Hcaptcha', () => { }, }, hcaptcha: { - render: renderMock, execute: executeMock, + render: renderMock, + setData: jest.fn(), }, window: null, }; @@ -227,11 +232,12 @@ describe('Hcaptcha', () => { 'chalexpired-callback': expect.any(Function), 'error-callback': expect.any(Function), })); - expect(executeMock).toHaveBeenCalledWith({ + expect(sandbox.hcaptcha.setData).toHaveBeenCalledWith('widget-id', { rqdata: '{"some":"data"}', mfa_phoneprefix: '44', mfa_phone: '+441234567890', }); + expect(executeMock).toHaveBeenCalledWith('widget-id'); const renderConfig = renderMock.mock.calls[0][1]; renderConfig['open-callback'](); @@ -274,20 +280,20 @@ describe('Hcaptcha', () => { const query = getApiQueryParams(component); expect(html).toContain('var hcaptchaConfig = '); - expect(html).toContain('const rqdata = hcaptchaConfig.rqdata;'); - expect(html).toContain('const phonePrefix = hcaptchaConfig.phonePrefix;'); - expect(html).toContain('const phoneNumber = hcaptchaConfig.phoneNumber;'); + expect(html).toContain('setData(hcaptchaConfig.verifyData || {});'); + expect(html).toContain('hcaptcha.setData(hcaptchaWidgetId, data || {});'); expect(html).not.toContain(''); - expect(html).not.toContain('const rqdata = ";window.ReactNativeWebView.postMessage("rqdata")'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"site\\")\\u003c/script\\u003e'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"debug\\")\\u003c/script\\u003e'); expect(config.siteKey).toBe('site"'); expect(config.backgroundColor).toBe('red\';window.ReactNativeWebView.postMessage("bg");//'); - expect(config.rqdata).toBe('";window.ReactNativeWebView.postMessage("rqdata");//'); - expect(config.phonePrefix).toBe('44";window.ReactNativeWebView.postMessage("prefix");//'); - expect(config.phoneNumber).toBe('+44123\');window.ReactNativeWebView.postMessage("phone");//'); + expect(config.verifyData).toEqual({ + rqdata: '";window.ReactNativeWebView.postMessage("rqdata");//', + mfa_phoneprefix: '44";window.ReactNativeWebView.postMessage("prefix");//', + mfa_phone: '+44123\');window.ReactNativeWebView.postMessage("phone");//', + }); expect(config.theme).toEqual(theme); expect(config.debugInfo['']).toBe(''); @@ -424,7 +430,8 @@ describe('Hcaptcha', () => { const [{ reset, markUsed }] = onMessage.mock.calls[0]; reset(); - expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith('onloadCallback();'); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('reset(); setData(')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); @@ -495,6 +502,40 @@ describe('Hcaptcha', () => { }); }); + it('uses verifyParams over legacy props and injects buffered journey data', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap', x: 1, y: 2 }); + + const component = render( + + ); + + expect(getSerializedConfig(component).verifyData).toEqual({ + rqdata: 'preferred', + mfa_phoneprefix: '44', + mfa_phone: '+44123', + userjourney: [ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'screen', ac: 'tap', x: 1, y: 2 }, + }), + ], + }); + }); + it('opens hcaptcha links externally and blocks navigation in the WebView', () => { const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); const component = render( diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 71e26f2..d09b238 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,7 +112,7 @@ 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 87af608..8059f46 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -46,7 +46,7 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` diff --git a/__tests__/journey.test.js b/__tests__/journey.test.js new file mode 100644 index 0000000..d6f7255 --- /dev/null +++ b/__tests__/journey.test.js @@ -0,0 +1,157 @@ +import React from 'react'; +import { AppRegistry, Text, View } from 'react-native'; +import { fireEvent, render } from '@testing-library/react-native'; +import JourneyWrapper from '../journey/wrapper'; +import { + __unsafeResetJourneyRuntime, + clearJourneyEvents, + disableJourneyConsumer, + enableJourneyConsumer, + emitJourneyEvent, + initJourneyTracking, + peekJourneyEvents, + registerJourneyNavigationContainer, +} from '../journey'; + +describe('journey runtime', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + __unsafeResetJourneyRuntime(); + }); + + it('installs the wrapper provider once and buffers events before the first consumer mounts', () => { + const wrapperSpy = jest.spyOn(AppRegistry, 'setWrapperComponentProvider'); + + initJourneyTracking(); + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' }); + + expect(wrapperSpy).toHaveBeenCalledTimes(1); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'screen', ac: 'tap' }, + }), + ]); + }); + + it('captures initial and subsequent navigation transitions', () => { + const listeners = new Set(); + const route = { current: { key: 'home-key', name: 'Home' } }; + const navigation = { + addListener: jest.fn((eventName, listener) => { + expect(eventName).toBe('state'); + listeners.add(listener); + return () => listeners.delete(listener); + }), + getCurrentRoute: jest.fn(() => route.current), + }; + + initJourneyTracking(); + registerJourneyNavigationContainer(navigation); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + + clearJourneyEvents(); + route.current = { key: 'settings-key', name: 'Settings' }; + listeners.forEach((listener) => listener()); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'screen', sc: 'Home', ac: 'disappear' }, + }), + expect.objectContaining({ + m: { id: 'screen', sc: 'Settings', ac: 'appear' }, + }), + ]); + }); + + it('re-emits the current screen when a consumer restarts on the same route', () => { + const navigation = { + addListener: jest.fn((eventName, listener) => { + expect(eventName).toBe('state'); + return () => listener; + }), + getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })), + }; + + initJourneyTracking(); + registerJourneyNavigationContainer(navigation); + clearJourneyEvents(); + + enableJourneyConsumer(); + disableJourneyConsumer(); + enableJourneyConsumer(); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + }); + + it('emits click events from the automatic wrapper', () => { + initJourneyTracking(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: '21', ac: 'tap', x: 5, y: 6 }, + }), + ]); + }); + + it('emits drag events from the automatic wrapper when movement exceeds threshold', () => { + initJourneyTracking(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 30 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 20, pageY: 20, target: 30 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'drag', + v: 'View', + m: { id: '30', ac: 'drag_start', x: 20, y: 20 }, + }), + expect.objectContaining({ + k: 'drag', + v: 'View', + m: expect.objectContaining({ id: '30', ac: 'drag_end', x: 20, y: 20 }), + }), + ]); + }); +}); diff --git a/index.d.ts b/index.d.ts index e247d0c..50c046d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { HcaptchaProps } from './Hcaptcha'; +import Hcaptcha, { HcaptchaProps } from './Hcaptcha'; type ConfirmHcaptchaProps = Omit & { /** @@ -31,4 +31,16 @@ export default class ConfirmHcaptcha extends React.Component void; + /** + * Stops automatic event recording for this captcha instance. + */ + stopEvents: () => void; } + +export function initJourneyTracking(options?: { + navigationContainerRef?: unknown; +}): void; + +export function registerJourneyNavigationContainer(ref: unknown): void; + +export { Hcaptcha }; diff --git a/index.js b/index.js index 3443afb..e3f5a42 100644 --- a/index.js +++ b/index.js @@ -2,11 +2,39 @@ import React, { PureComponent } from 'react'; import { Modal, SafeAreaView, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import Hcaptcha from './Hcaptcha'; import PropTypes from 'prop-types'; +import { disableJourneyConsumer, enableJourneyConsumer } from './journey'; +export { initJourneyTracking, registerJourneyNavigationContainer } from './journey'; +export { default as Hcaptcha } from './Hcaptcha'; class ConfirmHcaptcha extends PureComponent { state = { show: false, }; + hasJourneyConsumer = false; + componentDidMount() { + this.syncJourneyConsumer(false, this.props.userJourney); + } + componentDidUpdate(prevProps) { + this.syncJourneyConsumer(prevProps.userJourney, this.props.userJourney); + } + componentWillUnmount() { + this.syncJourneyConsumer(this.props.userJourney, false); + } + syncJourneyConsumer(previousValue, nextValue) { + if (!previousValue && nextValue && !this.hasJourneyConsumer) { + enableJourneyConsumer(); + this.hasJourneyConsumer = true; + } else if (previousValue && !nextValue && this.hasJourneyConsumer) { + disableJourneyConsumer(); + this.hasJourneyConsumer = false; + } + } + stopEvents = () => { + if (this.hasJourneyConsumer) { + disableJourneyConsumer(); + this.hasJourneyConsumer = false; + } + }; show = () => { this.setState({ show: true }); }; @@ -42,6 +70,8 @@ class ConfirmHcaptcha extends PureComponent { useSafeAreaView, phonePrefix, phoneNumber, + userJourney, + verifyParams, } = this.props; const WrapperComponent = useSafeAreaView === false ? View : SafeAreaView; @@ -71,6 +101,9 @@ class ConfirmHcaptcha extends PureComponent { debug={debug} phonePrefix={phonePrefix} phoneNumber={phoneNumber} + userJourney={userJourney} + verifyParams={verifyParams} + _journeyManagedExternally={true} /> ); @@ -178,6 +211,12 @@ ConfirmHcaptcha.propTypes = { debug: PropTypes.object, phonePrefix: PropTypes.string, phoneNumber: PropTypes.string, + userJourney: PropTypes.bool, + verifyParams: PropTypes.shape({ + phoneNumber: PropTypes.string, + phonePrefix: PropTypes.string, + rqdata: PropTypes.string, + }), }; ConfirmHcaptcha.defaultProps = { @@ -201,6 +240,8 @@ ConfirmHcaptcha.defaultProps = { debug: {}, phonePrefix: null, phoneNumber: null, + userJourney: false, + verifyParams: undefined, }; export default ConfirmHcaptcha; diff --git a/journey/index.js b/journey/index.js new file mode 100644 index 0000000..fc07d85 --- /dev/null +++ b/journey/index.js @@ -0,0 +1,14 @@ +export { + __unsafeResetJourneyRuntime, + clearJourneyEvents, + disableJourneyConsumer, + drainJourneyEvents, + enableJourneyConsumer, + emitJourneyEvent, + initJourneyTracking, + isJourneyCapturing, + peekJourneyEvents, + registerJourneyNavigationContainer, + resolveJourneyIdentifier, +} from './runtime'; +export { JOURNEY_MAX_EVENTS, JourneyEventKind, JourneyField } from './schema'; diff --git a/journey/runtime.js b/journey/runtime.js new file mode 100644 index 0000000..7b14483 --- /dev/null +++ b/journey/runtime.js @@ -0,0 +1,229 @@ +import { AppRegistry } from 'react-native'; +import { createJourneyEvent, cloneJourneyEvent, JourneyEventKind, JourneyField, JOURNEY_MAX_EVENTS } from './schema'; + +const state = { + activeConsumers: 0, + capturing: false, + currentRoute: null, + events: [], + initialized: false, + navigationCleanup: null, + navigationTarget: null, + wrapperInstalled: false, +}; + +const isFunction = (value) => typeof value === 'function'; + +const normalizeNavigationTarget = (target) => { + if (!target) { + return null; + } + + if (typeof target === 'object' && 'current' in target) { + return target.current || null; + } + + return target; +}; + +const setCapturing = (value) => { + state.capturing = Boolean(value); +}; + +const clearNavigationCleanup = () => { + if (!state.navigationCleanup) { + return; + } + + if (isFunction(state.navigationCleanup)) { + state.navigationCleanup(); + } else if (isFunction(state.navigationCleanup.remove)) { + state.navigationCleanup.remove(); + } + + state.navigationCleanup = null; +}; + +const getRouteIdentity = (route) => { + if (!route || typeof route.name !== 'string' || route.name.length === 0) { + return null; + } + + return { + key: typeof route.key === 'string' ? route.key : undefined, + name: route.name, + }; +}; + +const sameRoute = (left, right) => { + if (!left || !right) { + return false; + } + + if (left.key && right.key) { + return left.key === right.key; + } + + return left.name === right.name; +}; + +const pushEvent = (event) => { + if (!state.capturing) { + return; + } + + state.events.push(event); + + if (state.events.length > JOURNEY_MAX_EVENTS) { + state.events.splice(0, state.events.length - JOURNEY_MAX_EVENTS); + } +}; + +const installWrapperProvider = () => { + if (state.wrapperInstalled || !AppRegistry || !isFunction(AppRegistry.setWrapperComponentProvider)) { + return; + } + + AppRegistry.setWrapperComponentProvider(() => require('./wrapper').default); + state.wrapperInstalled = true; +}; + +export const initJourneyTracking = (options = {}) => { + if (!state.initialized) { + state.initialized = true; + setCapturing(true); + installWrapperProvider(); + } + + if (options.navigationContainerRef) { + registerJourneyNavigationContainer(options.navigationContainerRef); + } +}; + +export const registerJourneyNavigationContainer = (target) => { + if (!state.initialized) { + initJourneyTracking(); + } + + const navigationTarget = normalizeNavigationTarget(target); + if (!navigationTarget || navigationTarget === state.navigationTarget) { + return; + } + + clearNavigationCleanup(); + state.navigationTarget = navigationTarget; + + if (!isFunction(navigationTarget.addListener) || !isFunction(navigationTarget.getCurrentRoute)) { + return; + } + + const emitCurrentRoute = () => { + const currentRoute = getRouteIdentity(navigationTarget.getCurrentRoute()); + if (!currentRoute || sameRoute(state.currentRoute, currentRoute)) { + return; + } + + if (state.currentRoute) { + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: state.currentRoute.name, + [JourneyField.action]: 'disappear', + })); + } + + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: currentRoute.name, + [JourneyField.action]: 'appear', + })); + state.currentRoute = currentRoute; + }; + + state.navigationCleanup = navigationTarget.addListener('state', emitCurrentRoute); + emitCurrentRoute(); +}; + +export const enableJourneyConsumer = () => { + if (!state.initialized) { + initJourneyTracking(); + } + + const wasInactive = state.activeConsumers === 0; + state.activeConsumers += 1; + setCapturing(true); + + if (wasInactive && state.currentRoute) { + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: state.currentRoute.name, + [JourneyField.action]: 'appear', + })); + } +}; + +export const disableJourneyConsumer = () => { + if (state.activeConsumers > 0) { + state.activeConsumers -= 1; + } + + if (state.activeConsumers === 0) { + clearJourneyEvents(); + setCapturing(false); + } +}; + +export const emitJourneyEvent = (kind, view, metadata = {}) => { + pushEvent(createJourneyEvent(kind, view, metadata)); +}; + +export const peekJourneyEvents = () => state.events.map(cloneJourneyEvent); + +export const drainJourneyEvents = () => { + const snapshot = peekJourneyEvents(); + clearJourneyEvents(); + return snapshot; +}; + +export const clearJourneyEvents = () => { + state.events.length = 0; +}; + +const readIdentifierFromProps = (props) => { + if (!props || typeof props !== 'object') { + return null; + } + + return props.nativeID || props.testID || props.accessibilityLabel || null; +}; + +export const resolveJourneyIdentifier = (nativeEvent) => { + const targetInst = nativeEvent?._targetInst; + let current = targetInst; + + while (current) { + const identifier = readIdentifierFromProps(current.memoizedProps); + if (identifier) { + return String(identifier); + } + current = current.return; + } + + if (nativeEvent && nativeEvent.target != null) { + return String(nativeEvent.target); + } + + return 'unknown'; +}; + +export const isJourneyCapturing = () => state.capturing; + +export const __unsafeResetJourneyRuntime = () => { + clearNavigationCleanup(); + state.activeConsumers = 0; + state.capturing = false; + state.currentRoute = null; + state.events = []; + state.initialized = false; + state.navigationTarget = null; + state.wrapperInstalled = false; +}; diff --git a/journey/schema.js b/journey/schema.js new file mode 100644 index 0000000..c4ee019 --- /dev/null +++ b/journey/schema.js @@ -0,0 +1,47 @@ +export const JOURNEY_MAX_EVENTS = 50; + +export const JourneyEventKind = Object.freeze({ + screen: 'screen', + click: 'click', + drag: 'drag', + gesture: 'gesture', + edit: 'edit', +}); + +export const JourneyField = Object.freeze({ + kind: 'k', + view: 'v', + timestamp: 'ts', + metadata: 'm', + id: 'id', + screen: 'sc', + action: 'ac', + value: 'val', + x: 'x', + y: 'y', + index: 'idx', + section: 'sct', + item: 'it', + target: 'tt', + control: 'ct', + gesture: 'gt', + state: 'gs', + taps: 'tap', + containerView: 'cv', + length: 'ln', + compose: 'comp', +}); + +export const getJourneyTimestamp = () => Math.floor(Date.now() / 1000); + +export const createJourneyEvent = (kind, view, metadata = {}) => ({ + [JourneyField.timestamp]: getJourneyTimestamp(), + [JourneyField.kind]: kind, + [JourneyField.view]: view, + [JourneyField.metadata]: metadata, +}); + +export const cloneJourneyEvent = (event) => ({ + ...event, + [JourneyField.metadata]: { ...event[JourneyField.metadata] }, +}); diff --git a/journey/wrapper.js b/journey/wrapper.js new file mode 100644 index 0000000..f953ccd --- /dev/null +++ b/journey/wrapper.js @@ -0,0 +1,116 @@ +import React, { useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { emitJourneyEvent, isJourneyCapturing, resolveJourneyIdentifier } from './runtime'; +import { JourneyEventKind, JourneyField } from './schema'; + +const TAP_DURATION_MS = 300; +const DRAG_THRESHOLD_PX = 10; +const SCROLL_DOMINANCE_RATIO = 2; +const SCROLL_THRESHOLD_PX = 24; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +const getPoint = (nativeEvent) => ({ + x: typeof nativeEvent.pageX === 'number' ? nativeEvent.pageX : nativeEvent.locationX, + y: typeof nativeEvent.pageY === 'number' ? nativeEvent.pageY : nativeEvent.locationY, +}); + +const getDirection = (dx, dy) => { + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? 'horizontal:right' : 'horizontal:left'; + } + + return dy >= 0 ? 'vertical:down' : 'vertical:up'; +}; + +const JourneyWrapper = ({ children }) => { + const gestureRef = useRef(null); + + const handleTouchStart = (event) => { + if (!isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + gestureRef.current = { + identifier: resolveJourneyIdentifier(nativeEvent), + point: getPoint(nativeEvent), + startedAt: Date.now(), + }; + }; + + const handleTouchEnd = (event) => { + const gesture = gestureRef.current; + gestureRef.current = null; + + if (!gesture || !isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + const endPoint = getPoint(nativeEvent); + const dx = endPoint.x - gesture.point.x; + const dy = endPoint.y - gesture.point.y; + const distance = Math.sqrt((dx * dx) + (dy * dy)); + const duration = Date.now() - gesture.startedAt; + const baseMetadata = { + [JourneyField.id]: gesture.identifier, + [JourneyField.x]: endPoint.x, + [JourneyField.y]: endPoint.y, + }; + + if (distance < DRAG_THRESHOLD_PX && duration <= TAP_DURATION_MS) { + emitJourneyEvent(JourneyEventKind.click, 'View', { + ...baseMetadata, + [JourneyField.action]: 'tap', + }); + return; + } + + const axisDominant = Math.max(Math.abs(dx), Math.abs(dy)) / Math.max(Math.min(Math.abs(dx), Math.abs(dy)), 1); + if (distance >= SCROLL_THRESHOLD_PX && axisDominant >= SCROLL_DOMINANCE_RATIO) { + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { + ...baseMetadata, + [JourneyField.action]: 'scroll_start', + [JourneyField.value]: getDirection(dx, dy), + }); + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { + ...baseMetadata, + [JourneyField.action]: 'scroll_end', + [JourneyField.value]: Number(distance.toFixed(2)), + }); + return; + } + + emitJourneyEvent(JourneyEventKind.drag, 'View', { + ...baseMetadata, + [JourneyField.action]: 'drag_start', + }); + emitJourneyEvent(JourneyEventKind.drag, 'View', { + ...baseMetadata, + [JourneyField.action]: 'drag_end', + [JourneyField.value]: Number(distance.toFixed(2)), + }); + }; + + const handleTouchCancel = () => { + gestureRef.current = null; + }; + + return ( + + {children} + + ); +}; + +export default JourneyWrapper; From 506708519e6a9dd4bf18b1987bcbdcc46e525192 Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 26 Mar 2026 09:40:01 -0400 Subject: [PATCH 02/13] feat(journeys): refine automatic touch classification and identifiers Phase 2 improves the quality of automatic touch capture without changing the public journey surface. Scope of work: - classify scroll-like motion during touch move so axis-dominant gestures emit ScrollView start/end events instead of being inferred only at gesture end - keep the event volume intentionally sparse by emitting only start/end transitions, with no continuous drag or scroll stream - upgrade identifier resolution so semantic metadata from nativeID, testID, or accessibilityLabel can replace numeric target tags when richer information becomes available mid-gesture - expand journey runtime tests to cover scroll classification, identifier upgrades, and semantic-over-numeric precedence Review notes applied before commit: - kept phase scope limited to runtime and wrapper behavior only - avoided introducing any new public API or diagnostics surface in this commit - verified that the resulting wire shape still uses the existing Android-compatible event schema --- __tests__/journey.test.js | 81 ++++++++++++++++++++++++++++ journey/runtime.js | 62 +++++++++++++++++++--- journey/wrapper.js | 109 ++++++++++++++++++++++++++++++-------- 3 files changed, 222 insertions(+), 30 deletions(-) diff --git a/__tests__/journey.test.js b/__tests__/journey.test.js index d6f7255..2614f64 100644 --- a/__tests__/journey.test.js +++ b/__tests__/journey.test.js @@ -11,6 +11,7 @@ import { initJourneyTracking, peekJourneyEvents, registerJourneyNavigationContainer, + resolveJourneyIdentifier, } from '../journey'; describe('journey runtime', () => { @@ -154,4 +155,84 @@ describe('journey runtime', () => { }), ]); }); + + it('emits scroll-shaped drag events when movement is axis-dominant', () => { + initJourneyTracking(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 40 }, + }); + fireEvent(wrapper, 'touchMove', { + nativeEvent: { pageX: 35, pageY: 3, target: 40 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 55, pageY: 4, target: 40 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'drag', + v: 'ScrollView', + m: { id: '40', ac: 'scroll_start', x: 35, y: 3, val: 'horizontal:right' }, + }), + expect.objectContaining({ + k: 'drag', + v: 'ScrollView', + m: expect.objectContaining({ id: '40', ac: 'scroll_end', x: 55, y: 4 }), + }), + ]); + }); + + it('upgrades numeric targets to semantic identifiers when richer metadata becomes available', () => { + initJourneyTracking(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 41 }, + }); + fireEvent(wrapper, 'touchMove', { + nativeEvent: { + pageX: 25, + pageY: 2, + target: 41, + _dispatchInstances: { + pendingProps: { testID: 'checkout-cta' }, + return: null, + }, + }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 30, pageY: 4, target: 41 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'checkout-cta', ac: 'scroll_start', x: 25, y: 2, val: 'horizontal:right' }, + }), + expect.objectContaining({ + m: expect.objectContaining({ id: 'checkout-cta', ac: 'scroll_end' }), + }), + ]); + }); + + it('prefers semantic identifiers over numeric fallbacks during resolution', () => { + expect(resolveJourneyIdentifier({ + target: 99, + _dispatchInstances: { + pendingProps: { nativeID: 'primary-action' }, + return: null, + }, + })).toBe('primary-action'); + }); }); diff --git a/journey/runtime.js b/journey/runtime.js index 7b14483..ae0c1f5 100644 --- a/journey/runtime.js +++ b/journey/runtime.js @@ -13,6 +13,7 @@ const state = { }; const isFunction = (value) => typeof value === 'function'; +const numericIdentifierPattern = /^\d+$/; const normalizeNavigationTarget = (target) => { if (!target) { @@ -196,23 +197,70 @@ const readIdentifierFromProps = (props) => { return props.nativeID || props.testID || props.accessibilityLabel || null; }; -export const resolveJourneyIdentifier = (nativeEvent) => { - const targetInst = nativeEvent?._targetInst; - let current = targetInst; +const readIdentifierFromNode = (node) => { + let current = node; while (current) { - const identifier = readIdentifierFromProps(current.memoizedProps); + const identifier = readIdentifierFromProps(current.memoizedProps) + || readIdentifierFromProps(current.pendingProps) + || readIdentifierFromProps(current.stateNode?.props); + if (identifier) { return String(identifier); } + current = current.return; } - if (nativeEvent && nativeEvent.target != null) { - return String(nativeEvent.target); + return null; +}; + +const readIdentifierFromNodeList = (value) => { + if (!value) { + return null; + } + + const nodes = Array.isArray(value) ? value : [value]; + for (const node of nodes) { + const identifier = readIdentifierFromNode(node); + if (identifier) { + return identifier; + } + } + + return null; +}; + +const getIdentifierRank = (value) => { + if (!value || value === 'unknown') { + return 0; + } + + return numericIdentifierPattern.test(String(value)) ? 1 : 2; +}; + +export const resolveJourneyIdentifier = (nativeEvent, fallbackIdentifier) => { + const directIdentifier = readIdentifierFromProps(nativeEvent); + const fiberIdentifier = readIdentifierFromNodeList(nativeEvent?._targetInst) + || readIdentifierFromNodeList(nativeEvent?._dispatchInstances); + const targetIdentifier = nativeEvent && nativeEvent.target != null ? String(nativeEvent.target) : null; + const candidates = [ + fallbackIdentifier, + directIdentifier ? String(directIdentifier) : null, + fiberIdentifier, + targetIdentifier, + 'unknown', + ]; + + let bestIdentifier = 'unknown'; + + for (const candidate of candidates) { + if (getIdentifierRank(candidate) > getIdentifierRank(bestIdentifier)) { + bestIdentifier = candidate; + } } - return 'unknown'; + return bestIdentifier; }; export const isJourneyCapturing = () => state.capturing; diff --git a/journey/wrapper.js b/journey/wrapper.js index f953ccd..c921e07 100644 --- a/journey/wrapper.js +++ b/journey/wrapper.js @@ -27,6 +27,36 @@ const getDirection = (dx, dy) => { return dy >= 0 ? 'vertical:down' : 'vertical:up'; }; +const getGestureSnapshot = (nativeEvent, gesture) => { + const point = getPoint(nativeEvent); + const dx = point.x - gesture.point.x; + const dy = point.y - gesture.point.y; + + return { + direction: getDirection(dx, dy), + distance: Math.sqrt((dx * dx) + (dy * dy)), + dx, + dy, + point, + }; +}; + +const getGestureKind = (distance, dx, dy) => { + if (distance < DRAG_THRESHOLD_PX) { + return null; + } + + const axisDominant = Math.max(Math.abs(dx), Math.abs(dy)) / Math.max(Math.min(Math.abs(dx), Math.abs(dy)), 1); + + return distance >= SCROLL_THRESHOLD_PX && axisDominant >= SCROLL_DOMINANCE_RATIO ? 'scroll' : 'drag'; +}; + +const createBaseMetadata = (identifier, point) => ({ + [JourneyField.id]: identifier, + [JourneyField.x]: point.x, + [JourneyField.y]: point.y, +}); + const JourneyWrapper = ({ children }) => { const gestureRef = useRef(null); @@ -38,11 +68,42 @@ const JourneyWrapper = ({ children }) => { const nativeEvent = event.nativeEvent || {}; gestureRef.current = { identifier: resolveJourneyIdentifier(nativeEvent), + kind: null, point: getPoint(nativeEvent), startedAt: Date.now(), }; }; + const handleTouchMove = (event) => { + const gesture = gestureRef.current; + + if (!gesture || !isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + const snapshot = getGestureSnapshot(nativeEvent, gesture); + const kind = getGestureKind(snapshot.distance, snapshot.dx, snapshot.dy); + + gesture.identifier = resolveJourneyIdentifier(nativeEvent, gesture.identifier); + + if (!kind || gesture.kind) { + return; + } + + gesture.kind = kind; + const metadata = { + ...createBaseMetadata(gesture.identifier, snapshot.point), + [JourneyField.action]: kind === 'scroll' ? 'scroll_start' : 'drag_start', + }; + + if (kind === 'scroll') { + metadata[JourneyField.value] = snapshot.direction; + } + + emitJourneyEvent(JourneyEventKind.drag, kind === 'scroll' ? 'ScrollView' : 'View', metadata); + }; + const handleTouchEnd = (event) => { const gesture = gestureRef.current; gestureRef.current = null; @@ -52,18 +113,12 @@ const JourneyWrapper = ({ children }) => { } const nativeEvent = event.nativeEvent || {}; - const endPoint = getPoint(nativeEvent); - const dx = endPoint.x - gesture.point.x; - const dy = endPoint.y - gesture.point.y; - const distance = Math.sqrt((dx * dx) + (dy * dy)); + gesture.identifier = resolveJourneyIdentifier(nativeEvent, gesture.identifier); + const snapshot = getGestureSnapshot(nativeEvent, gesture); const duration = Date.now() - gesture.startedAt; - const baseMetadata = { - [JourneyField.id]: gesture.identifier, - [JourneyField.x]: endPoint.x, - [JourneyField.y]: endPoint.y, - }; + const baseMetadata = createBaseMetadata(gesture.identifier, snapshot.point); - if (distance < DRAG_THRESHOLD_PX && duration <= TAP_DURATION_MS) { + if (!gesture.kind && snapshot.distance < DRAG_THRESHOLD_PX && duration <= TAP_DURATION_MS) { emitJourneyEvent(JourneyEventKind.click, 'View', { ...baseMetadata, [JourneyField.action]: 'tap', @@ -71,29 +126,36 @@ const JourneyWrapper = ({ children }) => { return; } - const axisDominant = Math.max(Math.abs(dx), Math.abs(dy)) / Math.max(Math.min(Math.abs(dx), Math.abs(dy)), 1); - if (distance >= SCROLL_THRESHOLD_PX && axisDominant >= SCROLL_DOMINANCE_RATIO) { - emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { - ...baseMetadata, - [JourneyField.action]: 'scroll_start', - [JourneyField.value]: getDirection(dx, dy), - }); + const finalKind = gesture.kind || getGestureKind(snapshot.distance, snapshot.dx, snapshot.dy) || 'drag'; + + if (finalKind === 'scroll') { + if (!gesture.kind) { + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { + ...baseMetadata, + [JourneyField.action]: 'scroll_start', + [JourneyField.value]: snapshot.direction, + }); + } + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { ...baseMetadata, [JourneyField.action]: 'scroll_end', - [JourneyField.value]: Number(distance.toFixed(2)), + [JourneyField.value]: Number(snapshot.distance.toFixed(2)), }); return; } - emitJourneyEvent(JourneyEventKind.drag, 'View', { - ...baseMetadata, - [JourneyField.action]: 'drag_start', - }); + if (!gesture.kind) { + emitJourneyEvent(JourneyEventKind.drag, 'View', { + ...baseMetadata, + [JourneyField.action]: 'drag_start', + }); + } + emitJourneyEvent(JourneyEventKind.drag, 'View', { ...baseMetadata, [JourneyField.action]: 'drag_end', - [JourneyField.value]: Number(distance.toFixed(2)), + [JourneyField.value]: Number(snapshot.distance.toFixed(2)), }); }; @@ -105,6 +167,7 @@ const JourneyWrapper = ({ children }) => { From 60e8c1a809d7a713174f7884c3fee69a5f9a698d Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 26 Mar 2026 09:41:39 -0400 Subject: [PATCH 03/13] feat(journeys): add runtime diagnostics and stopEvents coverage Phase 3 adds the remaining runtime ergonomics and diagnostics around automatic journey collection. Scope of work: - allow initJourneyTracking to enable optional journey debug logging without affecting default production behavior - add an optional runtime stats callback that reports buffer size, consumer count, capture state, wrapper installation state, and current route snapshot for diagnostics and tests - export JourneyTrackingOptions and JourneyRuntimeStats typings for the public init surface - add test coverage for diagnostics behavior and for the existing ConfirmHcaptcha stopEvents() lifecycle path Review notes applied before commit: - kept diagnostics opt-in and side-effect free unless explicitly enabled - preserved the existing capture lifecycle semantics while exposing observability around them - limited the public API expansion to typed init options rather than adding another control surface --- __tests__/ConfirmHcaptcha.test.js | 30 ++++++++++++++++++++ __tests__/journey.test.js | 22 +++++++++++++++ index.d.ts | 19 +++++++++++-- journey/runtime.js | 46 +++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/__tests__/ConfirmHcaptcha.test.js b/__tests__/ConfirmHcaptcha.test.js index ccfeba9..6565db8 100644 --- a/__tests__/ConfirmHcaptcha.test.js +++ b/__tests__/ConfirmHcaptcha.test.js @@ -4,6 +4,12 @@ import { Modal, SafeAreaView } from 'react-native'; import Hcaptcha from '../Hcaptcha'; import ConfirmHcaptcha from '../index'; +import { + __unsafeResetJourneyRuntime, + emitJourneyEvent, + initJourneyTracking, + peekJourneyEvents, +} from '../journey'; describe('ConfirmHcaptcha', () => { const getModal = (component) => component.UNSAFE_getByType(Modal); @@ -13,6 +19,7 @@ describe('ConfirmHcaptcha', () => { beforeEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); + __unsafeResetJourneyRuntime(); }); it('renders ConfirmHcaptcha with minimum props after show() is called', () => { @@ -258,6 +265,29 @@ describe('ConfirmHcaptcha', () => { expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); }); + it('stopEvents() disables capture for the current consumer and clears the shared buffer', () => { + initJourneyTracking(); + const component = render( + + ); + const instance = getInstance(component); + + emitJourneyEvent('click', 'View', { id: 'before-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toHaveLength(1); + + act(() => { + instance.stopEvents(); + }); + + emitJourneyEvent('click', 'View', { id: 'after-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toEqual([]); + }); + it('backdrop and back-button handlers call hide(source) and emit cancel events', () => { const onMessage = jest.fn(); const component = render( diff --git a/__tests__/journey.test.js b/__tests__/journey.test.js index 2614f64..6fe1954 100644 --- a/__tests__/journey.test.js +++ b/__tests__/journey.test.js @@ -235,4 +235,26 @@ describe('journey runtime', () => { }, })).toBe('primary-action'); }); + + it('publishes runtime stats and optional debug logs', () => { + const onStats = jest.fn(); + const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}); + + initJourneyTracking({ debug: true, onStats }); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' }); + enableJourneyConsumer(); + disableJourneyConsumer(); + + expect(debugSpy).toHaveBeenCalledWith('[hcaptcha] journey', expect.objectContaining({ + k: 'click', + v: 'View', + })); + expect(onStats).toHaveBeenCalledWith(expect.objectContaining({ + activeConsumers: 0, + bufferedEvents: 0, + capturing: false, + initialized: true, + wrapperInstalled: true, + })); + }); }); diff --git a/index.d.ts b/index.d.ts index 50c046d..438f2d1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,21 @@ import React from 'react'; import Hcaptcha, { HcaptchaProps } from './Hcaptcha'; +export type JourneyRuntimeStats = { + activeConsumers: number; + bufferedEvents: number; + capturing: boolean; + currentRoute: { key?: string; name: string } | null; + initialized: boolean; + wrapperInstalled: boolean; +}; + +export type JourneyTrackingOptions = { + navigationContainerRef?: unknown; + debug?: boolean; + onStats?: (stats: JourneyRuntimeStats) => void; +}; + type ConfirmHcaptchaProps = Omit & { /** * Indicates whether the passive mode is enabled; when true, the modal won't be shown at all @@ -37,9 +52,7 @@ export default class ConfirmHcaptcha extends React.Component void; } -export function initJourneyTracking(options?: { - navigationContainerRef?: unknown; -}): void; +export function initJourneyTracking(options?: JourneyTrackingOptions): void; export function registerJourneyNavigationContainer(ref: unknown): void; diff --git a/journey/runtime.js b/journey/runtime.js index ae0c1f5..66b3279 100644 --- a/journey/runtime.js +++ b/journey/runtime.js @@ -5,10 +5,12 @@ const state = { activeConsumers: 0, capturing: false, currentRoute: null, + debug: false, events: [], initialized: false, navigationCleanup: null, navigationTarget: null, + statsListener: null, wrapperInstalled: false, }; @@ -31,6 +33,33 @@ const setCapturing = (value) => { state.capturing = Boolean(value); }; +const getJourneyRuntimeStats = () => ({ + activeConsumers: state.activeConsumers, + bufferedEvents: state.events.length, + capturing: state.capturing, + currentRoute: state.currentRoute ? { ...state.currentRoute } : null, + initialized: state.initialized, + wrapperInstalled: state.wrapperInstalled, +}); + +const publishStats = () => { + if (state.statsListener) { + state.statsListener(getJourneyRuntimeStats()); + } +}; + +const configureRuntime = (options = {}) => { + if (Object.prototype.hasOwnProperty.call(options, 'debug')) { + state.debug = Boolean(options.debug); + } + + if (Object.prototype.hasOwnProperty.call(options, 'onStats')) { + state.statsListener = isFunction(options.onStats) ? options.onStats : null; + } + + publishStats(); +}; + const clearNavigationCleanup = () => { if (!state.navigationCleanup) { return; @@ -78,6 +107,12 @@ const pushEvent = (event) => { if (state.events.length > JOURNEY_MAX_EVENTS) { state.events.splice(0, state.events.length - JOURNEY_MAX_EVENTS); } + + if (state.debug && typeof console !== 'undefined' && isFunction(console.debug)) { + console.debug('[hcaptcha] journey', event); + } + + publishStats(); }; const installWrapperProvider = () => { @@ -96,6 +131,8 @@ export const initJourneyTracking = (options = {}) => { installWrapperProvider(); } + configureRuntime(options); + if (options.navigationContainerRef) { registerJourneyNavigationContainer(options.navigationContainerRef); } @@ -113,6 +150,7 @@ export const registerJourneyNavigationContainer = (target) => { clearNavigationCleanup(); state.navigationTarget = navigationTarget; + publishStats(); if (!isFunction(navigationTarget.addListener) || !isFunction(navigationTarget.getCurrentRoute)) { return; @@ -138,6 +176,7 @@ export const registerJourneyNavigationContainer = (target) => { [JourneyField.action]: 'appear', })); state.currentRoute = currentRoute; + publishStats(); }; state.navigationCleanup = navigationTarget.addListener('state', emitCurrentRoute); @@ -160,6 +199,8 @@ export const enableJourneyConsumer = () => { [JourneyField.action]: 'appear', })); } + + publishStats(); }; export const disableJourneyConsumer = () => { @@ -171,6 +212,8 @@ export const disableJourneyConsumer = () => { clearJourneyEvents(); setCapturing(false); } + + publishStats(); }; export const emitJourneyEvent = (kind, view, metadata = {}) => { @@ -187,6 +230,7 @@ export const drainJourneyEvents = () => { export const clearJourneyEvents = () => { state.events.length = 0; + publishStats(); }; const readIdentifierFromProps = (props) => { @@ -270,8 +314,10 @@ export const __unsafeResetJourneyRuntime = () => { state.activeConsumers = 0; state.capturing = false; state.currentRoute = null; + state.debug = false; state.events = []; state.initialized = false; state.navigationTarget = null; + state.statsListener = null; state.wrapperInstalled = false; }; From c781301a5cc90d35888faf3347af84ee53a47d30 Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 26 Mar 2026 10:04:05 -0400 Subject: [PATCH 04/13] fix(journeys): inject fresh payloads at verify time and keep capture alive This change fixes the timing and lifecycle issues in the automatic journey implementation. Scope of work: - remove mount-time verifyData baking from the WebView HTML config and switch the initial verification path to a ready-handshake model - emit an internal ready signal after hcaptcha.render(...) and inject a fresh setData(widgetId, payload) + execute(widgetId) script from React Native when the widget is ready - keep the reset path data-driven while avoiding an unnecessary widget reset on the initial verify path - change the journey runtime so capture continues after init even when the active consumer count falls back to zero, preserving app-level history between captcha mounts - tighten current-route re-emission so enable/disable churn does not accumulate duplicate appear events when the current screen is already buffered - make ConfirmHcaptcha.stopEvents() clear the current journey buffer without shutting down global post-init capture - add tests for ring-buffer overflow, peek vs drain semantics, post-consumer buffering, ready-time payload injection, and error/cancel buffer preservation - update snapshots to reflect the new internal ready handshake and removal of baked verifyData from HTML Review notes applied before commit: - the initial verify path now matches the plan and Android semantics by building payloads at verification time rather than render time - runtime capture semantics now consistently follow the once-at-init model described in V3 - tests cover the previously missing lifecycle and buffering cases that could regress silently --- Hcaptcha.js | 59 +++++------ __tests__/ConfirmHcaptcha.test.js | 12 ++- __tests__/Hcaptcha.test.js | 99 +++++++++++++------ .../ConfirmHcaptcha.test.js.snap | 11 +-- __tests__/__snapshots__/Hcaptcha.test.js.snap | 11 +-- __tests__/journey.test.js | 79 ++++++++++++++- index.js | 4 +- journey/runtime.js | 14 +-- 8 files changed, 195 insertions(+), 94 deletions(-) diff --git a/Hcaptcha.js b/Hcaptcha.js index cd99b2b..ce81e70 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -26,6 +26,8 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; +const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; + const serializeForInlineScript = (value) => JSON.stringify(value) .replace(/ - `try { reset(); setData(${serializeForInlineScript(payload)}); execute(); } catch (e) { window.ReactNativeWebView.postMessage((e && e.name) || 'error'); } true;`; +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`; @@ -198,17 +200,6 @@ const Hcaptcha = ({ [debug] ); - const verifyData = useMemo( - () => buildVerifyData({ - phoneNumber, - phonePrefix, - rqdata, - userJourney: journeyEnabled ? peekJourneyEvents() : undefined, - verifyParams, - }), - [journeyEnabled, phoneNumber, phonePrefix, rqdata, verifyParams] - ); - const serializedWebViewConfig = useMemo( () => serializeForInlineScript({ apiUrl, @@ -217,9 +208,8 @@ const Hcaptcha = ({ siteKey: siteKey || '', size: normalizedSize, theme: normalizedTheme, - verifyData, }), - [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, siteKey, verifyData] + [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, siteKey] ); const generateTheWebViewContent = useMemo( @@ -256,20 +246,13 @@ const Hcaptcha = ({ try { console.log("challenge onload starting"); hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}"); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { console.log("challenge failed to render:", e); window.ReactNativeWebView.postMessage(e.name); } - try { - console.log("showing challenge"); - setData(hcaptchaConfig.verifyData || {}); - execute(); - } catch (e) { - console.log("failed to show challenge:", e); - window.ReactNativeWebView.postMessage(e.name); - } }; var onDataCallback = function(response) { window.ReactNativeWebView.postMessage(response); @@ -341,6 +324,19 @@ const Hcaptcha = ({ }, [isLoading, onMessage]); const webViewRef = useRef(null); + const injectVerifyData = (resetFirst = false) => { + if (!webViewRef.current) { + return; + } + + webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({ + phoneNumber, + phonePrefix, + rqdata, + userJourney: journeyEnabled ? peekJourneyEvents() : undefined, + verifyParams, + }), resetFirst)); + }; // This shows ActivityIndicator till webview loads hCaptcha images const renderLoading = () => ( @@ -352,15 +348,7 @@ const Hcaptcha = ({ ); const reset = () => { - if (webViewRef.current) { - webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({ - phoneNumber, - phonePrefix, - rqdata, - userJourney: journeyEnabled ? peekJourneyEvents() : undefined, - verifyParams, - }))); - } + injectVerifyData(true); }; return ( @@ -389,6 +377,11 @@ const Hcaptcha = ({ }} mixedContentMode={'always'} onMessage={(e) => { + if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) { + injectVerifyData(); + return; + } + e.reset = reset; e.success = true; if (e.nativeEvent.data === 'open') { @@ -433,4 +426,4 @@ const styles = StyleSheet.create({ }); export default Hcaptcha; -export { buildVerifyData }; +export { buildVerifyData, HCAPTCHA_READY_EVENT }; diff --git a/__tests__/ConfirmHcaptcha.test.js b/__tests__/ConfirmHcaptcha.test.js index 6565db8..3ab7908 100644 --- a/__tests__/ConfirmHcaptcha.test.js +++ b/__tests__/ConfirmHcaptcha.test.js @@ -265,7 +265,7 @@ describe('ConfirmHcaptcha', () => { expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); }); - it('stopEvents() disables capture for the current consumer and clears the shared buffer', () => { + it('stopEvents() clears the current shared buffer without tearing down global capture', () => { initJourneyTracking(); const component = render( { instance.stopEvents(); }); - emitJourneyEvent('click', 'View', { id: 'after-stop', ac: 'tap' }); expect(peekJourneyEvents()).toEqual([]); + + emitJourneyEvent('click', 'View', { id: 'after-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'after-stop', ac: 'tap' }, + }), + ]); }); it('backdrop and back-button handlers call hide(source) and emit cancel events', () => { diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 1454fb2..7adc0d3 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -3,8 +3,8 @@ import vm from 'vm'; import { act, render, waitFor } from '@testing-library/react-native'; import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-native'; -import Hcaptcha from '../Hcaptcha'; -import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking } from '../journey'; +import Hcaptcha, { HCAPTCHA_READY_EVENT } from '../Hcaptcha'; +import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking, peekJourneyEvents } from '../journey'; import { getLastInjectJavaScriptMock, resetWebViewMockState, @@ -96,11 +96,7 @@ describe('Hcaptcha', () => { expect(config.size).toBe('normal'); expect(config.backgroundColor).toBe('rgba(0.1, 0.1, 0.1, 0.4)'); expect(config.theme).toBe('contrast'); - expect(config.verifyData).toEqual({ - rqdata: '{"some":"data"}', - mfa_phoneprefix: '44', - mfa_phone: '+441234567890', - }); + expect(config.verifyData).toBeUndefined(); expect(config.debugInfo).toMatchObject({ customDebug: 'enabled', rnver_0_0_0: true, @@ -161,7 +157,7 @@ describe('Hcaptcha', () => { }); }); - it('loads the external api script dynamically and preserves the onload render/execute flow', () => { + it('loads the external api script dynamically and signals RN when the widget is ready', () => { const component = render( { 'chalexpired-callback': expect.any(Function), 'error-callback': expect.any(Function), })); - expect(sandbox.hcaptcha.setData).toHaveBeenCalledWith('widget-id', { - rqdata: '{"some":"data"}', - mfa_phoneprefix: '44', - mfa_phone: '+441234567890', - }); - expect(executeMock).toHaveBeenCalledWith('widget-id'); + expect(postMessageMock).toHaveBeenCalledWith(HCAPTCHA_READY_EVENT); + expect(sandbox.hcaptcha.setData).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); const renderConfig = renderMock.mock.calls[0][1]; renderConfig['open-callback'](); @@ -280,8 +273,8 @@ describe('Hcaptcha', () => { const query = getApiQueryParams(component); expect(html).toContain('var hcaptchaConfig = '); - expect(html).toContain('setData(hcaptchaConfig.verifyData || {});'); expect(html).toContain('hcaptcha.setData(hcaptchaWidgetId, data || {});'); + expect(html).toContain(`window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}");`); expect(html).not.toContain(''); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"site\\")\\u003c/script\\u003e'); @@ -289,11 +282,7 @@ describe('Hcaptcha', () => { expect(config.siteKey).toBe('site"'); expect(config.backgroundColor).toBe('red\';window.ReactNativeWebView.postMessage("bg");//'); - expect(config.verifyData).toEqual({ - rqdata: '";window.ReactNativeWebView.postMessage("rqdata");//', - mfa_phoneprefix: '44";window.ReactNativeWebView.postMessage("prefix");//', - mfa_phone: '+44123\');window.ReactNativeWebView.postMessage("phone");//', - }); + expect(config.verifyData).toBeUndefined(); expect(config.theme).toEqual(theme); expect(config.debugInfo['']).toBe(''); @@ -449,6 +438,28 @@ describe('Hcaptcha', () => { })); }); + it('injects fresh verify data only after the widget signals readiness', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'before-ready', ac: 'tap', x: 1, y: 2 }); + const component = render( + + ); + + emitJourneyEvent('click', 'View', { id: 'after-mount', ac: 'tap', x: 3, y: 4 }); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"before-ready"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"after-mount"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.not.stringContaining('reset();')); + }); + it('emits an expired message when a forwarded token is not marked used', async () => { jest.useFakeTimers(); const onMessage = jest.fn(); @@ -522,18 +533,44 @@ describe('Hcaptcha', () => { /> ); - expect(getSerializedConfig(component).verifyData).toEqual({ - rqdata: 'preferred', - mfa_phoneprefix: '44', - mfa_phone: '+44123', - userjourney: [ - expect.objectContaining({ - k: 'click', - v: 'View', - m: { id: 'screen', ac: 'tap', x: 1, y: 2 }, - }), - ], + expect(getSerializedConfig(component).verifyData).toBeUndefined(); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"preferred"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phone":"+44123"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"userjourney"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"screen"')); + }); + + it('preserves buffered journey events after error and cancel messages', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'preserved', ac: 'tap' }); + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: 'challenge-closed' } }); + getWebView(component).props.onMessage({ nativeEvent: { data: 'webview-error' } }); }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'preserved', ac: 'tap' }, + }), + ]); }); it('opens hcaptcha links externally and blocks navigation in the WebView', () => { diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index d09b238..96cffbd 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -112,7 +112,7 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show()