fix: Errors now match the PurchasesError interface - #1919
Draft
AlvaroBrey wants to merge 7 commits into
Draft
Conversation
This was referenced Aug 26, 2026
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
force-pushed
the
alvarobrey/normalize-bridge-errors
branch
from
September 1, 2026 14:44
45ab1a5 to
3ef4b23
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PurchasesErrorinterface.readableErrorCode,underlyingErrorMessageanduserCancelledwere declared at the top level but only ever existed nested insideuserInfo, and on iOS the payload never reached JS at all.NSError, because React Native forwards only that error'suserInfoto JS. Android has always done the equivalent at its own reject site.code24) now carryreadableErrorCode: "UNSUPPORTED_ERROR". They were hand built and rejected without an error container, so they reached JS with no readable code and no payload.@revenuecat/purchases-typescript-internalat a committed local tarball, which is temporary and must be swapped for the published version before merge. It leaves thefiles_to_update_phc_versionpattern inreleaseVersionPatternsmatching nothing, and the packed tarball the Expo example installs cannot seelocal-phc/.userInfo.readableErrorCodefor iOS consumers.isPurchasesErrortype 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-commonbuilds a consistent error payload, but each host framework decides how it reaches JS. On Android this SDK passeserrorContainer.getInfo()as the rejectuserInfo, so the payload arrives. On iOS it passederrorContainer.error, and React Native forwards only thatNSError's ownuserInfo, 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 sharedErrorContainer, 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
withNormalizedErrorsatsrc/purchases.ts, the single point every call routes through, so no per method changes are needed.NativeEventEmitterkeeps the unwrapped module. It only needsaddListenerandremoveListeners, neither of which can carry an SDK error.rejectPromiseWithBlock:error:mergeserror.infointo the rejectedNSError'suserInfo. The two inline error container rejects now route through that helper rather than duplicating it.createUnsupportedErrorWithDescriptionsetsreadable_error_code, and all six of its reject sites go through a newrejectPromiseWithBlock:unsupportedError:. Without it these errors have a numeric code and would be normalized into aPurchasesErrorcarrying an empty readable code, which is worse than the field being absent.Not visible in the diff: the two
cancelled purchase*assertions inindex.test.jsusetoEqual, an exact shape match, so they had to gain the newly presentuserInfo. They still assert exact shape, and no coverage was removed.test: Restore the proxy identity and userInfo assertionsputs back two tests an earlier cleanup commit in this branch removed; they are the only end to end proof that the proxy preservesErroridentity and Android's richeruserInfo.This SDK has no iOS unit test target, only maestro e2e, so the
NSErrormerge is compile checked here.purchases-hybrid-commonpins the merge's input instead.Regression gate: the maestro
purchase_through_paywallflow. Its first step waits forEntitlements: none, which only renders ifgetCustomerInfo()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 threwCannot read property 'length' of null.Limitations:
userCancelledis still written twice: the normalizer sets it from the payload, then eight.catchblocks here re-derive it from the code. Deferred to a major, because deriving it once flips non-purchase rejections fromnulltofalse.readableErrorCode;purchases-js-hybrid-mappingsnever emits one.Rejected:
NativeEventEmittertoo. It works, but it is indirection on a path that returns void and can never carry an SDK error.react-native-purchases-ui's modules. Their rejections use named codes such asPAYWALL_ERROR, never an error container, so normalization would be a no-op at best and misleading at worst.