Skip to content

feat: react-native-web support - #118

Open
CAMOBAP wants to merge 9 commits into
masterfrom
feat/react-native-web-support
Open

feat: react-native-web support#118
CAMOBAP wants to merge 9 commits into
masterfrom
feat/react-native-web-support

Conversation

@CAMOBAP

@CAMOBAP CAMOBAP commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What

Adds react-native-web support: ConfirmHcaptcha and Hcaptcha now render in a browser, with the public API, prop names and onMessage contract unchanged.

On native the widget lives in a WebView and the two sides talk over postMessage / injectJavaScript. On web the host page is a browser, so the widget renders straight into the document via @hcaptcha/loader — the same loader the native HTML already uses — and the message channel collapses into direct calls.

How

  • hcaptchaShared.js holds the logic both platforms share (loader config, verify payload, size/theme normalization, timeouts), extracted from Hcaptcha.js with no behaviour change.
  • Hcaptcha.web.js is picked up by bundler platform-extension resolution; index.js and the type definitions are untouched.
  • reactNativeVersion.web.js exists because react-native-web ships no Libraries/Core/ReactNativeVersion, and that deep import fails to resolve once react-native is aliased. This is why the split is two files rather than one file with a runtime Platform.OS check — static imports of react-native-webview and that version path are resolved at bundle time, before any runtime branch could run.

Testing

suite tests
test:native 60
test:web 38
perf 24
  • __tests_web__/ runs under jsdom with react-native aliased to react-native-web, exactly as a consumer's bundler would alias it. Only the api.js network fetch is stubbed — the component, loader wiring and event mapping all run for real.
  • Journey tracking is covered on web (consumer lifecycle, stopEvents(), multiple simultaneous consumers, buffered events reaching the verify payload). The shared journey runtime is unmocked.
  • Render counts are guarded with React's Profiler, so a dropped useMemo/useCallback that adds a re-render fails the build.
  • CI now runs npm run build:web. Jest fakes bundler resolution, so only a real webpack build proves the .web.js extensions and the alias actually link.

Verified manually in Chrome against live hCaptcha: the visual challenge renders and returns a token, markUsed() and hide() fire, and the loader is configured correctly (render=explicit, sitekey-derived host, hl from languageCode).

Also in here

  • reassure.config.js has been inert since feat: add reassure testing to control render count #95 — nothing imported it, and reassure has no config-file auto-discovery. Adding @testing-library/react made that matter, because reassure then had two testing libraries to choose between and fell back to auto-detection. It is now loaded via setupFilesAfterEnv.
  • Replaced TouchableWithoutFeedback with Pressable and moved pointerEvents into the style — both logged deprecation warnings from react-native-web in a real browser. accessible and focusable were already set by TouchableWithoutFeedback, so native accessibility behaviour is unchanged.

Note for reviewers

npm run perf:baseline fails on master with master's own lockfile — 11 of 24 tests fail with TypeError: Cannot read properties of null (reading 'useMemo'). It is not introduced by this branch (the perf suite passes 24/24 here), but it means CI's baseline step on master push fails, no baseline is cached, and the PR performance job's if: cache-hit == 'true' guard silently skips perf:compare. So no perf comparison is running on PRs today. Worth a separate issue.

CAMOBAP and others added 9 commits August 25, 2026 22:55
Moves the platform-agnostic helpers (theme/size normalization, debug info,
verify data, loader config, render config, shared timeouts) into
hcaptchaShared.js, and isolates the
`react-native/Libraries/Core/ReactNativeVersion` deep import behind
reactNativeVersion.js.

That import is the one thing in the library no web bundler can resolve once
`react-native` is aliased to `react-native-web`, so it needs a platform split
before a web build is possible. Hcaptcha.js still re-exports buildDebugInfo,
buildVerifyData and HCAPTCHA_READY_EVENT, so the native behaviour and the
existing tests are unchanged.

Also fixes one latent bug carried over in the move: `custom` was computed as
`typeof theme === 'object'`, and because `typeof null === 'object'` an absent
theme told the loader to expect a custom theme that never arrives. Now guarded
with an explicit null check. No test pinned the old behaviour, and
ConfirmHcaptcha masked it by defaulting `theme` to 'light'; only direct
`Hcaptcha` consumers without a theme were affected. Revert this hunk alone if
the wire change is unwanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a web build so the same ConfirmHcaptcha / Hcaptcha code runs under
react-native-web with no application changes.

Rather than emulating a WebView in the browser, Hcaptcha.web.js loads api.js
with @hcaptcha/loader — already a dependency, and the same loader the native
inline HTML uses — and renders the widget directly into the document. That keeps
setData/execute/reset as direct API calls instead of messages that need an
injectJavaScript transport, so the full native feature set carries over: the
{ nativeEvent: { data } } event shape, success/reset/markUsed, size and theme
normalization, rqdata, verifyParams, MFA phone props, User Journeys, the
15s loading timeout, the 120s token expiry and script-error retry.

react-native-web supplies Modal, SafeAreaView and the rest, so no Modal,
animation or WebView shims are needed; `react-native` -> `react-native-web` is
the only bundler alias. react-native-web and react-dom are declared as optional
peer dependencies, so native-only installs are unaffected.

Testing:
- __tests_web__/ holds a jsdom Jest project (`npm run test:web`, 27 tests). It
  stubs only the network fetch of api.js and the widget API it installs on
  `window`; the component renders for real. It lives outside __tests__ so the
  native project's default testMatch cannot pick it up.
- `npm test` now runs both projects.
- Verified end to end in a real browser against live hCaptcha via the new
  `npm run web` example: the widget loads, a visual challenge opens inside the
  RNW modal, and with hCaptcha's always-pass test key the token reaches
  onMessage and markUsed()/hide() fire.

Also adds `modulePathIgnorePatterns` for __e2e__ to the native Jest config. A
generated __e2e__/host app carries its own node_modules including a second copy
of React, which Jest resolved into, failing 32 unit tests locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config file has been inert since it was added in #95: nothing imported it,
and reassure has no config-file auto-discovery — `configure()` is meant to be
called from a Jest setup file. Every setting in it was dead, including the
`testingLibrary` pin.

That pin matters now. Adding @testing-library/react for the web suite means both
it and @testing-library/react-native are installed, and reassure was falling back
to auto-detection, warning once per perf test:

  Both '@testing-library/react-native' and '@testing-library/react' are
  installed. Using '@testing-library/react-native' by default.

Wiring the file into setupFilesAfterEnv makes the pin take effect and silences
the warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run test:web -- --coverage` writes a coverage/ report that showed up as
untracked noise in git status and, because eslint does not read .gitignore,
also tripped lint on the generated lcov-report sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the example in a browser logged two warnings from our own code:

  TouchableWithoutFeedback is deprecated. Please use Pressable.
  props.pointerEvents is deprecated. Use style.pointerEvents

Both are shared native/web files, so the fix applies to both platforms.

TouchableWithoutFeedback cloned its single child to attach handlers; Pressable
renders the view itself, so the wrapper View collapses into it. On native the
rendered element gains only inert props — accessibilityValue with all-undefined
fields, collapsable={false}, and Pressable's internal onFocus/onBlur. Notably
accessible={true} and focusable={true} were already set by
TouchableWithoutFeedback, so screen-reader and focus behaviour is unchanged.
On web the backdrop additionally gets cursor:pointer and touch-action.

Moving pointerEvents into the style broke a test that located the passive
container with UNSAFE_getByProps({pointerEvents:'none'}); the container now
carries a testID, matching the existing confirm-hcaptcha-backdrop convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The web suite never passed userJourney, so every journey path was unexecuted
under react-native-web: the enable/disable effect in Hcaptcha.web.js and
syncJourneyConsumer/stopEvents in index.js. The native suite covers all of it.

Nothing here is mocked — __mocks__/web.js stubs only the api.js fetch, so the
shared journey runtime runs for real, as it does on native.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Catches a dropped useMemo/useCallback in Hcaptcha.web.js turning a mount into a
mount-plus-rerender.

Uses React's own Profiler rather than reassure. These are absolute assertions on
commit counts, so there is no baseline to diff against and no second
perf:compare pipeline to maintain; running reassure's measureRenders outside its
CLI also prints an "incorrect Node.js configuration" banner on every test:web run.

Documents one existing redundancy rather than changing behaviour: emit() calls
setIsLoading(false) on every message, so React renders once more before bailing
out on the unchanged value even though isLoadingRef already tracks it. Hcaptcha.js
has the same shape, so any fix belongs on both at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jest fakes bundler resolution with moduleNameMapper and moduleFileExtensions, so
nothing in CI ever proved the web build actually links. A broken .web.js
platform extension or react-native-web alias would pass every test — which is
the exact failure reactNativeVersion.web.js exists to prevent, since
react-native-web ships no Libraries/Core/ReactNativeVersion for the native file
to resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dev server logged a 404 for /favicon.ico on every load. An inline empty icon
avoids the request without adding an asset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CAMOBAP CAMOBAP self-assigned this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant