Bump PHC to 19.3.0 and fix errors not matching PurchasesError - #579
Conversation
ajpallares
left a comment
There was a problem hiding this comment.
I think it makes sense!
Should we add some unit tests for this?
Also, I'll leave approval to someone with more ts knowledge 🙏
tonidero
left a comment
There was a problem hiding this comment.
Just a question, but if we've tested it with most functions on the SDK in both Android and iOS, it does make sense. Thank you so much for handling this!!
| target: any, | ||
| methodName: string | symbol | ||
| ): T { | ||
| const wrappedFn = function(this: any, ...args: any[]) { |
There was a problem hiding this comment.
I'm wondering if we could apply a fix here so that, if any parameters are of type Vue.js reference (or maybe use something as a proxy, like the presence of a toRaw function, use that. In order to avoid these issues we've had with Vue.js reactivity... But in any case, that can come in a separate PR.
There was a problem hiding this comment.
Yeah, I have another branch for that, but since I am not quite sure how to test it. It's in another branch. Just opened the draft PR #583
| const value = Reflect.get(target, prop, receiver); | ||
|
|
||
| if (typeof value === 'function') { | ||
| if (typeof prop === 'number') { |
There was a problem hiding this comment.
Hmm I'm wondering is this ever the case? If i'm not wrong here, prop is the function name right?
There was a problem hiding this comment.
I think this can occur if a function gets called with index access [0], but I will double check
There was a problem hiding this comment.
we might be able to skip this tbh
|
I should check this still works fine with Capacitor 8 before merging |
f42ff55 to
916bdec
Compare
40cfed8 to
582c345
Compare
582c345 to
35842ee
Compare
723b4e1 to
a42bc2c
Compare
…1838) - Adds `normalizePurchasesError`, so errors reaching JS consumers match the `PurchasesError` interface this package declares. Before this change they did not meet the interface, on either React Native or Capacitor: - React Native: the bridge forwards only `NSError.userInfo` to JS, so on iOS the `ErrorContainer.info` payload never arrived at all, and on Android it arrives nested under `userInfo`. `readableErrorCode`, `underlyingErrorMessage` and `userCancelled` are declared at the top level but were never there on either platform. - Capacitor: Android puts the payload under `data`; iOS passed nothing, so the same three fields were `undefined` and only `code` and `message` existed. Reported as RevenueCat/purchases-capacitor#302, open since v7. - iOS: `ErrorContainer.error` is now the original `NSError` rather than a re-wrapped copy with `readableErrorCode` injected into its `userInfo`. Only `react-native-purchases` ever read that key, and it now does that merge at its own reject site in RevenueCat/react-native-purchases#1919. `flutter`, `cordova` and `unity` read `info` and are unaffected. - Builds on #1635, which makes the web mapper emit a string `code`. **Must merge together with RevenueCat/purchases-capacitor#579 and RevenueCat/react-native-purchases#1919** - `userCancelled` is derived from `code` for every normalized error (SDK-4448). React Native's eight `.catch` blocks that re-derived it are removed in #1919, so it is present on every SDK error: `true` for a cancelled purchase on every platform (Capacitor and RN web never reported it), `false` elsewhere, where it was absent on Capacitor and on RN's non-purchase methods. Part of react-native-purchases#1756 and purchases-capacitor#302. Tracked in SDK-4450 and SDK-4448. ### Checklist - [x] A description about what and why you are contributing, even if it's trivial. - [x] The issue number(s) or PR number(s) in the description if you are contributing in response to those. - [x] If applicable, unit tests. <details><summary>Agent description</summary> ### Motivation This package builds a consistent error payload in `ErrorContainer.info`, but it does not own how that payload reaches JS. Each host framework nests it under a different key: React Native under `userInfo`, Capacitor under `data`. Both iOS paths dropped it entirely. Meanwhile the `PurchasesError` interface declares every field flat at the top level, a shape no bridge produces. The interface has been wrong since it was copied out of `react-native-purchases` in #455. It was already half wrong then: react-native-purchases#268 had found in 2021 that `readableErrorCode` actually lives in `userInfo`, added `userInfo: ErrorInfo` and deprecated the flat field, but left `underlyingErrorMessage` and `userCancelled` with the identical defect. Reported downstream as RevenueCat/purchases-capacitor#302, open since v7. ### Description - `normalizePurchasesError` reads the payload from `userInfo` or `data` and lifts the declared fields to the top level. It mutates the error in place and returns it: copying into a new object would discard the prototype and stack, breaking `instanceof Error` for consumers and `instanceof CapacitorException` on Capacitor. - It only acts on errors whose `code` is numeric. Plugin level rejections use names such as `UNIMPLEMENTED` or `PAYWALL_ERROR` and are left untouched. - `userInfo` is merged rather than replaced, so Capacitor gains the full payload it was missing while React Native keeps the richer one its bridge already provides. Both platforms now expose the same `userInfo`. - Only the normalizer lives here. Each SDK wraps its own native module and calls it on every rejection; the interception is bridge specific (React Native must never copy a TurboModule promise, Capacitor must keep `addListener().remove`), so it belongs with the bridge. - iOS: `ErrorContainer` no longer rebuilds the `NSError`. The long standing `readableErrorCode` special case goes with it. **Not visible in the diff:** the JS normalizer is exercised against hand written fixtures of each bridge envelope, taken from reading the bridges. Checked once by hand on 2026-09-10 against an iPhone 17 simulator, a Pixel emulator (React Native) and a Pixel emulator (Capacitor) with a bogus API key: every declared field was present with the declared type on all three, and the captured React Native iOS `userInfo` was a superset of the fixture. Nothing repeats that check automatically, which is the same gap that let the original mismatch persist. `react-native-purchases` has no iOS test target, so the merge it now performs is compile checked there; `ErrorContainerPayloadTests` here pins that merge's input across the three ways this package builds an `NSError`. **Regression gate:** `errorNormalizer.test.ts` "satisfies PurchasesError" across the per bridge fixtures, which is the contract the consumers' own wrappers rely on. The Hermes constraint that shaped the React Native wrapper (enumerating a TurboModule promise's own keys throws `Cannot read property 'length' of null`) is documented and tested in react-native-purchases#1919, where that wrapper now lives. **Limitations:** - Web errors still get an empty `readableErrorCode`, because `purchases-js-hybrid-mappings`' `mapPurchasesError` never emits one. That mapper is the right place to fix it. - Web `userInfo` carries `statusCode` and `backendErrorCode` lifted from `info`, but not `userCancelled`, which the mapper puts at the top level. Native `info` includes it for purchase errors. - `userCancelled` is `boolean | null` in the interface but never `null` at runtime any more. Narrowing the type waits for a major. - Only `react-native-purchases` and `purchases-capacitor` consume this package. `flutter`, `cordova` and `unity` forward `info` faithfully and need no changes. **Rejected:** - Matching `code` against `PURCHASES_ERROR_CODE` membership. That enum omits codes the native SDKs emit (36 to 41), and Android and iOS disagree on 28 and 36, so it would reject genuine errors, including the web purchase redemption ones. - Shipping the module wrapper from here as `withNormalizedErrors`. It was one place to test, but it encoded both bridges' quirks in shared code and made this package a runtime hook rather than a contract; an earlier version of this PR did that. - Keeping the iOS merge here. It exists only for React Native's bridge, and leaving it gave the other four hybrids a `userInfo` they never asked for while making Capacitor send the payload twice. - Flattening `info` onto the error in native code, matching `cordova` and `unity`. No bridge lets native add top level keys. - Guarding the code coercion so web kept its numeric `code` until the next major. It left react-native-purchases#1756 unfixed, and #1635 already stringifies at the source, so the guard would have been dead code. - Making the phantom interface fields optional. Honest, but it breaks compilation for anyone assigning them to a non-optional `string`. Growing the runtime gets the same honesty additively. </details> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes cross-platform error shape and iOS ErrorContainer forwarding; consumers must call the normalizer on rejections (coordinated PRs), but behavior is covered by new unit tests. > > **Overview** > Adds **`normalizePurchasesError`** to the TypeScript package so hybrid SDKs can lift bridge-nested payloads (`userInfo`, `data`, `info`) onto a flat **`PurchasesError`** shape in place—preserving prototypes/stacks and only touching numeric SDK codes. Jest fixtures cover React Native, Capacitor, and web envelopes; CI gains a **`typescript-tests`** job and lint runs via **`yarn lint`**. > > On iOS, **`ErrorContainer`** no longer re-wraps the **`NSError`** to inject **`readableErrorCode`** into **`userInfo`**; it forwards the original error and still fills **`info`** (including a **`rc_code_name`** fallback when **`readable_error_code`** is missing). New Swift tests lock the hybrid **`info`** contract. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b8259d1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
6381942 to
25a36ad
Compare
|
@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 25a36ad. Configure here.
0f51ee4 to
be2d40e
Compare
Dismissing: the branch has been rewritten since this approval and none of the current content was reviewed.
Supersedes purchases-capacitor#918.
call.reject dropped the error container's info dictionary, so iOS rejections reached JS with only a code and a message. Android has always received the same payload under `data`, which Capacitor fills from this argument.
readableErrorCode, underlyingErrorMessage and userCancelled are declared on PurchasesError but only ever arrived nested under `data`. The proxy src/index.ts already had for trackCustomPaywallImpression now runs normalizePurchasesError on every rejection, and re-attaches the `remove` property addListener puts on its promise, which chaining would drop.
Adds jest and a CircleCI job for it. The suite pins the exact error shape a consumer sees, including the legacy `data` key, and that addListener keeps `remove`.
tag-current-branch did not list run-unit-tests, so on release branches the tag could be pushed before the tests finished or after they failed.
be2d40e to
c9639d4
Compare
|
@ajpallares and @tonidero , I'm requesting re-review as I've taken over this PR and reworked it to match RevenueCat/react-native-purchases#1919 PHC now provides |
tonidero
left a comment
There was a problem hiding this comment.
Just a small concern... But don't consider it a blocker. Thanks for doing this!!
5f3a89f to
3269691
Compare
3269691 to
d57e714
Compare
) - Adds an iOS test target. The package had none, so every Swift change in this repo has only ever been compile checked. - Pins the shape `rejectWithErrorContainer` hands the bridge: the payload must sit under exactly one `data` level. `CAPPluginCallError` wraps the reject `data` argument itself, so passing an already wrapped payload buries it one level below where the JS layer reads it, and every declared field comes back empty. - Runs in the existing `run-tests-ios` job rather than a new one, which avoids a second mac executor and needs no change to the release gate, since that job is already in `tag-current-branch`'s `requires`. - Stacked on #579. Merge that one first. - [x] A description about what and why you are contributing, even if it's trivial. - [x] The issue number(s) or PR number(s) in the description if you are contributing in response to those. - [x] If applicable, unit tests. <details><summary>Agent description</summary> ### Motivation #579 changes what iOS passes to `call.reject`, which is the one thing in that PR no test could see. A commit on it buried the payload an extra level and reached CI green, because the jest suite builds its own envelope and so exercises the normalizer rather than the shape this bridge produces. Capacitor's two bridges differ in how they treat that argument, and the only evidence either way was reading Capacitor's own source. That reading went wrong once already and produced a regression that CI happily passed. ### Description - `Package.swift` gains a `.testTarget` depending on the existing target plus `Capacitor` and `PurchasesHybridCommon`, at `ios/Tests/RevenuecatPurchasesCapacitorTests`. The podspec glob is `ios/Sources/**`, so nothing here reaches CocoaPods consumers. - `PluginHelperExtensionsTests` builds a `CAPPluginCall` with its own handlers, captures the `CAPPluginCallError` that `rejectWithErrorContainer` produces, and asserts the payload sits under one `data` level with the code, message and readable code inside it. A second spec asserts the message and code travel outside that payload. - `yarn test:ios` runs it, wired into `run-tests-ios`. **Not visible in the diff:** the fixture builds a plain `NSError` in the RevenueCat domain carrying `readable_error_code` rather than using `ErrorCode`. purchases-hybrid-common exports only its own two libraries, so `RevenueCat` is not importable here, and adding a second pin of purchases-ios for tests alone would be worse than the problem. The error still exercises the same `ErrorContainer` derivation. **Regression gate:** `testPayloadSitsUnderExactlyOneDataLevel`. Reintroducing the extra nesting fails it with "the payload is nested twice" and leaves the other spec passing, checked locally on an iPhone 17 simulator. **Limitations:** this pins what we hand Capacitor, not the final JS object. The serialization that turns it into a `CapacitorException` lives inside Capacitor and is internal to that module. The end to end shape was confirmed by hand on a simulator for #579; covering it automatically would need the maestro e2e flow. </details> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test and CI wiring only; no production Swift or JS runtime behavior changes in this diff. > > **Overview** > Adds the repo’s first **native iOS unit tests** so Swift bridge behavior is checked beyond compile-only `verify:ios`. > > `Package.swift` defines a `.testTarget` at `ios/Tests/RevenuecatPurchasesCapacitorTests`. New specs exercise `rejectWithErrorContainer` and assert the Capacitor reject envelope keeps error fields under **exactly one** `data` level (avoiding double-wrapping that breaks JS), with code/message/readableErrorCode in the payload and message/code on the outer rejection. > > **`yarn test:ios`** runs `xcodebuild test` on an iPhone 17 simulator; CircleCI **`run-tests-ios`** invokes it after the existing iOS verify step, so release gates that already depend on that job pick up the tests without a new job. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 314b609. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
**This is an automatic release.** ## RevenueCat SDK ### 🐞 Bugfixes * Bump PHC to 19.3.0 and fix errors not matching PurchasesError (#579) via Cesar de la Vega (@vegaro) ### 📦 Dependency Updates * [AUTOMATIC BUMP] Updates purchases-hybrid-common to 19.3.1 (#926) via RevenueCat Git Bot (@RCGitBot) * [Android 10.22.1](https://github.com/RevenueCat/purchases-android/releases/tag/10.22.1) * [Android 10.22.0](https://github.com/RevenueCat/purchases-android/releases/tag/10.22.0) * [iOS 5.90.2](https://github.com/RevenueCat/purchases-ios/releases/tag/5.90.2) * [iOS 5.90.1](https://github.com/RevenueCat/purchases-ios/releases/tag/5.90.1) * [iOS 5.90.0](https://github.com/RevenueCat/purchases-ios/releases/tag/5.90.0) * [AUTOMATIC BUMP] Updates purchases-hybrid-common to 19.2.0 (#916) via RevenueCat Git Bot (@RCGitBot) ### 🔄 Other Changes * Bump fastlane-plugin-revenuecat_internal from `fc64a1a` to `9f7a03e` (#925) via dependabot[bot] (@dependabot[bot]) * Auto-merge PHC bump PRs (#924) via Álvaro Brey (@AlvaroBrey) * test(ios): Add an iOS test target pinning the reject payload shape (#919) via Álvaro Brey (@AlvaroBrey) * Bump fastlane from 2.240.0 to 2.240.1 (#922) via dependabot[bot] (@dependabot[bot]) * Bump fastlane-plugin-revenuecat_internal from `6db1da0` to `fc64a1a` (#923) via dependabot[bot] (@dependabot[bot]) * Bump fastlane from 2.239.0 to 2.240.0 (#917) via dependabot[bot] (@dependabot[bot]) * ci: approve the release hold automatically when the release PR is approved (#912) via Álvaro Brey (@AlvaroBrey) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Patch release pulls newer PHC and native billing SDKs and fixes purchase error typing, which can affect failure handling in purchase flows. > > **Overview** > **Automatic patch release** that publishes **13.6.1** for `@revenuecat/purchases-capacitor` and `@revenuecat/purchases-capacitor-ui`, including native plugin version strings on Android and iOS. > > The release notes call out a **bugfix** from updated **purchases-hybrid-common** (19.3.x): native reject payloads should align with **`PurchasesError`** again (#579). **`VERSIONS.md`** documents the new stack—PHC **19.3.1**, iOS **5.90.2**, Android **10.22.1**. > > In this PR diff, runtime code changes are limited to **version constant bumps**; **`CHANGELOG.md`**, **`CHANGELOG.latest.md`**, and **`.version`** are refreshed for the release. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a53bd15. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
PurchasesErrorinterface on both platforms.Purchasesruns throughnormalizePurchasesErrorfrompurchases-hybrid-common, inside theProxysrc/index.tsalready had fortrackCustomPaywallImpression.addListenerkeeps theremoveproperty Capacitor attaches to its promise.userCancelledreadsfalsefor every SDK error andtruefor a cancelled purchase, derived fromcode(SDK-4448). Released versions never had the field at the top level at all.run-unit-testsCircleCI job for it, and puts that job in the release tag gate alongside the existing test jobs.Closes #302. Tracked in SDK-4450 and SDK-4448.
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
#302 has reported since 2024 that rejected errors do not match the
PurchasesErrorinterface, most visibly on iOS. On Android, Capacitor puts whatever the bridge passes as the third argument ofrejectunder adataproperty, so the payload was there but in the wrong place. On iOS the bridge never passed the payload, so consumers got onlycodeandmessage.Description
rejectWithErrorContainerinPluginHelperExtensions.swiftpasseserror.infoas thedataargument ofcall.reject, so iOS rejections carry the same nested payload Android always had. Both bridges wrap that argument underdatathemselves.src/index.tswraps every function on the registered plugin. A rejected promise is chained throughnormalizePurchasesError, which lifts the payload to the top level, setsuserInfoto the whole payload, and derivesuserCancelledfromcode. It re-attachesremove, whichaddListenerputs on its promise for the deprecated non-awaited call style and a plain.thenwould drop.PurchasesWebrejects plain strings, and the normalizer only touches objects whosecodeis numeric.datakey, and arun-unit-testsCircleCI job.Before and after
Captured with an invalid API key. Before, on Android the five fields sat under
dataonly; on iOS the error carried nothing butcode,errorMessageandmessage. After, on both platforms, pinned by the test:iOS reports
INVALID_CREDENTIALSwhere Android reportsInvalidCredentialsError; the readable code names differ per platform today, tracked in SDK-4464.Captured by hand on a Pixel emulator on 2026-09-10 and on an iPhone 17 simulator on 2026-09-18, running the example app against a bogus API key. Both platforms produce the same object: one
datalevel with the five keys,codeas a string,userInfoholding the whole payload, anduserCancelled: false. iOS still reportsINVALID_CREDENTIALSwhere Android reportsInvalidCredentialsError, and itsmessageappends the underlying message, both of which predate this PR.Limitations:
datastays on the error for backwards compatibility. Nothing removes it.NOT_SUPPORTEDrejections use a string code and are not normalized. Pre-existing.Rejected:
withNormalizedErrors, which an earlier version of this stack did. It put Capacitor'sremovehandling and React Native's promise constraints into shared code and turned that package into a runtime hook rather than a set of contracts.Note
Medium Risk
Changes how all native rejections surface to app code (including userCancelled) and touches the iOS bridge error path; behavior is covered by new unit tests but affects every failed Purchases call.
Overview
Fixes rejected native plugin errors so they match the documented
PurchasesErrorshape (closes #302) and bumps purchases-hybrid-common to 19.3.0.On the JS side,
src/index.tsgeneralizes the existingProxy: every plugin method is wrapped so rejections run throughnormalizePurchasesError, whileaddListenerstill keeps Capacitor’sremoveon the returned promise andtrackCustomPaywallImpressionstill maps offering options for the bridge. On iOS,rejectWithErrorContainernow passeserror.infointocall.rejectso the bridge carries the same payload Android already nested underdata.Jest is added (
yarn test,jest.config.js) with tests for error shape and Capacitor method/promise parity. CircleCI runs a newrun-unit-testsjob on all branches and requires it before release tagging.Reviewed by Cursor Bugbot for commit d57e714. Bugbot is set up for automated code reviews on this repo. Configure here.