Skip to content

fix: Errors now match the PurchasesError interface - #1919

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

fix: Errors now match the PurchasesError interface#1919
AlvaroBrey wants to merge 15 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; they now go through the container, which derives the name from the code and domain.
  • Routes @revenuecat/purchases-typescript-internal to a committed local tarball through a root resolutions entry, which is temporary and must be removed once the published version lands. The dependency itself stays pinned to the released version, so the release version patterns keep matching and the packed tarball the Expo example installs resolves from npm (without the normalizer, so those builds compile but would not normalize at runtime).
  • Must merge together with feat: Normalize hybrid bridge errors to the PurchasesError interface 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.
  • On web (Expo Go, Rork, RN Web) userCancelled flips from false to true on cancellation with Fix web purchase cancellation error mapping purchases-hybrid-common#1635, now merged. The eight .catch blocks here compare error.code against a string enum, and web has sent a number since browser mode shipped in 8.11.0, so the comparison was always false. react-native-purchases#1759 patched the same symptom here and was closed in its favour.

Part of react-native-purchases#1756 and react-native-purchases#291 (the isPurchasesError type guard it asks for is not in this PR). Tracked in SDK-4450 and SDK-4448.

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 a local Proxy at src/purchases.ts, the single point every call routes through, that runs normalizePurchasesError on every rejection. It chains the returned promise and never copies it, because enumerating a TurboModule promise's own keys throws inside Hermes.
  • 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.
  • The six unsupported platform rejects go through a new rejectPromiseWithBlock:unsupportedErrorDescription:, which wraps the hand built NSError in an RCErrorContainer. purchases-hybrid-common derives readableErrorCode for any NSError in the RevenueCat domain, so nothing here spells the name out.

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. Checked once by hand on 2026-09-10 on an iPhone 17 simulator and a Pixel emulator with a bogus API key: both rejected with every declared field present and typed, and the iOS userInfo carried the native rc_* keys merged with the container payload.

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 in an earlier version of this wrapper, where copying the TurboModule promise's own properties threw Cannot read property 'length' of null.

Limitations:

  • userCancelled is now derived once, in purchases-hybrid-common, from code. The eight .catch blocks that re-derived it are gone, and non-purchase rejections carry userCancelled: false where the field was absent (SDK-4448). Truthiness is unchanged; only presence checks can tell.
  • 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; routing them through the container gives them a real readable code instead of a blank one.
  • Writing readable_error_code into that NSError by hand. It worked, but duplicated a string that purchases-hybrid-common now derives from the code.

Note

Medium Risk
Changes error object shape and iOS bridge rejection payload for all native promise failures; apps branching on error fields may see new top-level properties, and the PR depends on coordinated hybrid-common releases.

Overview
Native module rejections are normalized so JS errors match the PurchasesError shape (readableErrorCode, underlyingErrorMessage, userCancelled, etc.) instead of hiding those fields only in userInfo or dropping them on iOS.

JS: src/purchases.ts wraps NativeModules.RNPurchases (and the browser stub) in a local Proxy that runs normalizePurchasesError on every rejection at the single entry point; NativeEventEmitter still uses the unwrapped module. @revenuecat/purchases-typescript-internal is temporarily resolved to a local tarball until the published package includes the normalizer.

iOS: rejectPromiseWithBlock:error: merges RCErrorContainer.info into the rejected NSError’s userInfo so React Native forwards the full payload to JS. Unsupported-platform rejects route through a new helper that wraps them in an RCErrorContainer, which derives readable_error_code: UNSUPPORTED_ERROR from the code; scattered inline reject(...) calls use the shared helpers.

Tests: New errorNormalization.test.ts covers Android-shaped native rejections (interface, Error identity, preserved userInfo); cancelled-purchase expectations in index.test.js include userInfo after normalization.

Reviewed by Cursor Bugbot for commit 18f4815. Bugbot is set up for automated code reviews on this repo. Configure here.

@AlvaroBrey

Copy link
Copy Markdown
Member Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 18f4815. Configure here.

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.
purchases-hybrid-common now derives it from the code for every error,
so the eight catch blocks were writing the same value a second time.
Non-purchase failures read false instead of null. Refreshes the local
purchases-hybrid-common build to match.
The NSError builder and the reject call were only ever used as a pair,
and the readable_error_code it wrote by hand is now derived by
purchases-hybrid-common from the code and domain.
…common

The proxy is React Native specific: it chains the returned promise and
never copies it, because enumerating a TurboModule promise throws inside
Hermes. purchases-hybrid-common keeps normalizePurchasesError only.
Pin the dependency to the released version and route it to the local
tarball through resolutions instead, so the release version patterns
still match and the packed tarball installs in the Expo tester.
@AlvaroBrey
AlvaroBrey force-pushed the alvarobrey/normalize-bridge-errors branch from 27c5692 to 5572bdc Compare September 11, 2026 12:27
Jest sizes its worker pool from os.cpus(), which inside the medium
docker executor reports the host's cores rather than the container's
two, so every suite gets its own worker. Ten suites no longer fit in
the container's 4 GB and the Tests step gets killed part way through.
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