fix: Errors now match the PurchasesError interface - #1919
Draft
AlvaroBrey wants to merge 15 commits into
Draft
Conversation
AlvaroBrey
force-pushed
the
alvarobrey/normalize-bridge-errors
branch
from
September 1, 2026 14:44
45ab1a5 to
3ef4b23
Compare
AlvaroBrey
force-pushed
the
alvarobrey/normalize-bridge-errors
branch
from
September 9, 2026 15:11
3ef4b23 to
18f4815
Compare
Member
Author
|
@cursor review |
There was a problem hiding this comment.
✅ 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
force-pushed
the
alvarobrey/normalize-bridge-errors
branch
from
September 11, 2026 12:27
27c5692 to
5572bdc
Compare
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.
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; they now go through the container, which derives the name from the code and domain.@revenuecat/purchases-typescript-internalto a committed local tarball through a rootresolutionsentry, 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).userInfo.readableErrorCodefor iOS consumers.userCancelledflips fromfalsetotrueon cancellation with Fix web purchase cancellation error mapping purchases-hybrid-common#1635, now merged. The eight.catchblocks here compareerror.codeagainst 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
isPurchasesErrortype 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-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
Proxyatsrc/purchases.ts, the single point every call routes through, that runsnormalizePurchasesErroron every rejection. It chains the returned promise and never copies it, because enumerating a TurboModule promise's own keys throws inside Hermes.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.rejectPromiseWithBlock:unsupportedErrorDescription:, which wraps the hand builtNSErrorin anRCErrorContainer.purchases-hybrid-commonderivesreadableErrorCodefor anyNSErrorin the RevenueCat domain, so nothing here spells the name out.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. 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 iOSuserInfocarried the nativerc_*keys merged with the container payload.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 in an earlier version of this wrapper, where copying the TurboModule promise's own properties threwCannot read property 'length' of null.Limitations:
userCancelledis now derived once, inpurchases-hybrid-common, fromcode. The eight.catchblocks that re-derived it are gone, and non-purchase rejections carryuserCancelled: falsewhere the field was absent (SDK-4448). Truthiness is unchanged; only presence checks can tell.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.readable_error_codeinto thatNSErrorby hand. It worked, but duplicated a string thatpurchases-hybrid-commonnow 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
PurchasesErrorshape (readableErrorCode,underlyingErrorMessage,userCancelled, etc.) instead of hiding those fields only inuserInfoor dropping them on iOS.JS:
src/purchases.tswrapsNativeModules.RNPurchases(and the browser stub) in a localProxythat runsnormalizePurchasesErroron every rejection at the single entry point;NativeEventEmitterstill uses the unwrapped module.@revenuecat/purchases-typescript-internalis temporarily resolved to a local tarball until the published package includes the normalizer.iOS:
rejectPromiseWithBlock:error:mergesRCErrorContainer.infointo the rejectedNSError’suserInfoso React Native forwards the full payload to JS. Unsupported-platform rejects route through a new helper that wraps them in anRCErrorContainer, which derivesreadable_error_code: UNSUPPORTED_ERRORfrom the code; scattered inlinereject(...)calls use the shared helpers.Tests: New
errorNormalization.test.tscovers Android-shaped native rejections (interface,Erroridentity, preserveduserInfo); cancelled-purchase expectations inindex.test.jsincludeuserInfoafter normalization.Reviewed by Cursor Bugbot for commit 18f4815. Bugbot is set up for automated code reviews on this repo. Configure here.