Skip to content

feat: react-native-web support - #118

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

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

Conversation

@CAMOBAP

@CAMOBAP CAMOBAP commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #66.

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 CAMOBAP self-assigned this Sep 4, 2026
Comment thread Hcaptcha.web.js
orientation,
callbacks: callbacksRef.current,
}));
hasRenderedRef.current = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear isLoadingRef and isLoading after api.render() succeeds, matching native’s ready handler. Otherwise a loaded widget that has not emitted open/token/error still shows the loading overlay and reports a false loading timeout after 15s.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5c11121: isLoadingRef/isLoading are now cleared right after api.render() succeeds, matching native's ready handler. Added a test for an invisible widget that renders without emitting anything: no overlay, and no loading timeout after 15s. The render-count guard now expects one extra commit on load, which is the same as native's ready message.

@e271828- e271828- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second static pass.

Comment thread Hcaptcha.web.js
}
})
.catch((error) => {
emitRef.current((error && error.message) || (error && error.name) || 'error');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignore loader rejection after unmount. If the modal is hidden while api.js is loading, this still calls onMessage; reopening before that rejection lets the old instance’s error handler close the new challenge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 31a01b2: each load gets a generation number, and both unmount and any newer load (including retryApiLoad) invalidate it, so a stale resolve or reject does nothing. Tests cover a rejection after unmount and a stale load from a previous instance racing a remounted one.

Comment thread Hcaptcha.web.js Outdated
phoneNumber: pn,
phonePrefix: pp,
rqdata: rq,
userJourney: journeyEnabled ? peekJourneyEvents() : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the current journey flag at execution time. The pending loader retains the initial applyVerifyData closure, so disabling userJourney or calling stopEvents() before it resolves can still attach buffered journey events.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e50dbe6: the flag is now read through journeyEnabledRef when verify data is applied, the same way verifyInputsRef works, and applyVerifyData no longer depends on it. Added a test that turns userJourney off while the loader is pending and asserts the buffered event is not in setData.

CAMOBAP and others added 12 commits October 25, 2026 17:13
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>
Native clears isLoading on its ready event; web only did so on the first
emitted message. An invisible widget that rendered but had not opened yet
kept the overlay up and reported a false loading timeout after 15s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loader rejection that settled after the modal was hidden still called
onMessage; if the modal had been reopened meanwhile, the old instance's
error could close the new challenge. Each load now carries a generation
that unmount and later loads invalidate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pending loader kept the first render's applyVerifyData, so turning
userJourney off before api.js resolved still attached buffered journey
events to the verify payload. The flag is now read through a ref, like
the other verify inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CAMOBAP
CAMOBAP force-pushed the feat/react-native-web-support branch from e50dbe6 to 22ee283 Compare October 25, 2026 16:23
CAMOBAP and others added 2 commits October 25, 2026 17:52
ConfirmHcaptcha drives prewarm itself: it mounts the widget hidden before
show() and executes it through the Hcaptcha ref. The web widget has no ref
API and executes on mount, so prewarm on web ran a hidden verification at
mount and made show()/hide()/destroy() no-ops on the widget. Prewarm is now
gated to native in the wrapper, so web takes the default mount-on-show()
path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npm installs a local directory dependency's devDependencies, unlike a
registry install. The web tooling added unpinned react-dom, which resolves
to the newest React and nests a copy inside the library whenever the host
template pins an older one (RN 0.88 pins react 19.3.0, react-dom@* needs
^19.3.1). Metro then bundled two Reacts and every Hcaptcha hook call failed
with "Invalid hook call", failing 36 of 38 e2e tests on both platforms.

The copied library's devDependencies are now dropped before install,
matching what a consumer gets from the registry. Also refresh the new
shared modules alongside Hcaptcha.js when re-running e2e on an existing host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CAMOBAP
CAMOBAP force-pushed the feat/react-native-web-support branch from ec55f23 to fa24207 Compare October 25, 2026 18:06
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.

Web support with react-native-web-webview

2 participants