Skip to content

fix: Errors now match the PurchasesError interface - #1919

Draft
AlvaroBrey wants to merge 7 commits into
mainfrom
alvarobrey/normalize-bridge-errors
Draft

fix: Errors now match the PurchasesError interface#1919
AlvaroBrey wants to merge 7 commits into
mainfrom
alvarobrey/normalize-bridge-errors

Conversation

@AlvaroBrey

@AlvaroBrey AlvaroBrey commented Aug 24, 2026

Copy link
Copy Markdown
Member
  • Errors rejected by the native module now match the PurchasesError interface. readableErrorCode, underlyingErrorMessage and userCancelled were declared at the top level but only ever existed nested inside userInfo, and on iOS the payload never reached JS at all.
  • iOS merges the error container's payload into the rejected NSError, because React Native forwards only that error's userInfo to JS. Android has always done the equivalent at its own reject site.
  • Unsupported platform errors (code 24) now carry readableErrorCode: "UNSUPPORTED_ERROR". They were hand built and rejected without an error container, so they reached JS with no readable code and no payload.
  • Points @revenuecat/purchases-typescript-internal at a committed local tarball, which is temporary and must be swapped for the published version before merge. It leaves the files_to_update_phc_version pattern in releaseVersionPatterns matching nothing, and the packed tarball the Expo example installs cannot see local-phc/.
  • Must merge together with feat(typescript): Add normalizePurchasesError for hybrid bridge errors purchases-hybrid-common#1838 and Fix errors not matching PurchasesError purchases-capacitor#579. Landing the purchases-hybrid-common bump ahead of this one would drop userInfo.readableErrorCode for iOS consumers.
  • Part of react-native-purchases#291. That issue asks for an isPurchasesError type guard, which is not in this PR and stays open.

Checklist

  • A description about what and why you are contributing, even if it's trivial.

  • The issue number(s) or PR number(s) in the description if you are contributing in response to those.

  • If applicable, unit tests.

Agent description

Motivation

purchases-hybrid-common builds a consistent error payload, but each host framework decides how it reaches JS. On Android this SDK passes errorContainer.getInfo() as the reject userInfo, so the payload arrives. On iOS it passed errorContainer.error, and React Native forwards only that NSError's own userInfo, which never carried the payload. So the same error had two different shapes depending on platform, and neither matched the declared interface.

The iOS merge previously lived in purchases-hybrid-common's shared ErrorContainer, which meant every hybrid paid for a React Native bridge limitation. It moves here, next to the reject site that needs it, mirroring what the Android module already does.

Description

  • Wraps the native module in withNormalizedErrors at src/purchases.ts, the single point every call routes through, so no per method changes are needed.
  • NativeEventEmitter keeps the unwrapped module. It only needs addListener and removeListeners, neither of which can carry an SDK error.
  • rejectPromiseWithBlock:error: merges error.info into the rejected NSError's userInfo. The two inline error container rejects now route through that helper rather than duplicating it.
  • createUnsupportedErrorWithDescription sets readable_error_code, and all six of its reject sites go through a new rejectPromiseWithBlock:unsupportedError:. Without it these errors have a numeric code and would be normalized into a PurchasesError carrying an empty readable code, which is worse than the field being absent.

Not visible in the diff: the two cancelled purchase* assertions in index.test.js use toEqual, an exact shape match, so they had to gain the newly present userInfo. They still assert exact shape, and no coverage was removed. test: Restore the proxy identity and userInfo assertions puts back two tests an earlier cleanup commit in this branch removed; they are the only end to end proof that the proxy preserves Error identity and Android's richer userInfo.

This SDK has no iOS unit test target, only maestro e2e, so the NSError merge is compile checked here. purchases-hybrid-common pins the merge's input instead.

Regression gate: the maestro purchase_through_paywall flow. Its first step waits for Entitlements: none, which only renders if getCustomerInfo() resolves, so it catches a startup crash that every mocked unit test misses. It is what caught the Hermes bug fixed in RevenueCat/purchases-hybrid-common#1838, where enumerating a TurboModule promise's own keys threw Cannot read property 'length' of null.

Limitations:

  • userCancelled is still written twice: the normalizer sets it from the payload, then eight .catch blocks here re-derive it from the code. Deferred to a major, because deriving it once flips non-purchase rejections from null to false.
  • Web errors get an empty readableErrorCode; purchases-js-hybrid-mappings never emits one.

Rejected:

  • Wrapping the native module for NativeEventEmitter too. It works, but it is indirection on a path that returns void and can never carry an SDK error.
  • Wrapping react-native-purchases-ui's modules. Their rejections use named codes such as PAYWALL_ERROR, never an error container, so normalization would be a no-op at best and misleading at worst.
  • Leaving the six unsupported platform rejects alone. Their code is numeric, so the normalizer claims them either way; giving them a real readable code is better than letting them through with a blank one.

The bridge nests the payload under userInfo on Android and sends the NSError's
own userInfo on iOS, so readableErrorCode, underlyingErrorMessage and userInfo
did not appear where PurchasesError declares them.
React Native forwards only the NSError's userInfo to JS, so readableErrorCode
and underlyingErrorMessage never reached consumers on iOS. Android already does
the equivalent at its own reject site.
Removed in 46826a0 as duplicating purchases-hybrid-common coverage, but those
test the normalizer directly rather than this SDK's proxy wrapper.
They are RevenueCat errors with a numeric code, but were built by hand and
rejected without an ErrorContainer, so they reached JS with no readable code
and no payload. Routed through the same helper as every other error.
Temporary. Replace with the published version once the normalizer ships in
purchases-hybrid-common; drop the local-phc directory with it. Leaves the phc
version pattern in releaseVersionPatterns dead until then.
@AlvaroBrey
AlvaroBrey force-pushed the alvarobrey/normalize-bridge-errors branch from 45ab1a5 to 3ef4b23 Compare September 1, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant