Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
command: yarn prepare
- run:
name: Tests
command: yarn test
command: yarn test --maxWorkers=2

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is because I was seeing crashes on CI due to too many parallel workers. I took the maxWorkers approach from some of our other repos

- run:
name: API Tests
command: yarn tsc -p apitesters
Expand Down
2 changes: 1 addition & 1 deletion RNPurchases.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ Pod::Spec.new do |spec|
]

spec.dependency "React-Core"
spec.dependency "PurchasesHybridCommon", '19.2.0'
spec.dependency "PurchasesHybridCommon", '19.3.0'
spec.swift_version = '5.7'
end
70 changes: 70 additions & 0 deletions __tests__/errorNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { NativeModules } from "react-native";
import type { PurchasesError } from "@revenuecat/purchases-typescript-internal";

import Purchases from "../src/purchases";

/**
* The shape React Native builds on Android: PromiseImpl sends code, message and
* userInfo, and RNPurchasesModule passes ErrorContainer.info as that userInfo.
*/
function nativeRejection(): Error {
return Object.assign(new Error("There was a credentials issue."), {
code: "11",
message: "There was a credentials issue.",
userInfo: {
code: 11,
message: "There was a credentials issue.",
readableErrorCode: "InvalidCredentialsError",
readable_error_code: "InvalidCredentialsError",
underlyingErrorMessage: "Invalid API Key.",
},
nativeStackAndroid: [],
});
}

describe("errors rejected by the native module", () => {
beforeEach(() => {
NativeModules.RNPurchases.isConfigured.mockResolvedValue(true);
});

afterEach(() => {
jest.clearAllMocks();
});

it("match the PurchasesError interface", async () => {
NativeModules.RNPurchases.getCustomerInfo.mockRejectedValueOnce(nativeRejection());

const error: PurchasesError = await Purchases.getCustomerInfo().catch((caught: unknown) => caught);

expect(error.code).toBe("11");
expect(error.readableErrorCode).toBe("InvalidCredentialsError");
expect(error.underlyingErrorMessage).toBe("Invalid API Key.");
expect(error.userInfo.readableErrorCode).toBe("InvalidCredentialsError");
expect(error.userCancelled).toBe(false);
});

it("stay real Errors", async () => {
NativeModules.RNPurchases.getCustomerInfo.mockRejectedValueOnce(nativeRejection());

const error = await Purchases.getCustomerInfo().catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(Error);
expect(typeof (error as Error).stack).toBe("string");
});

it("keep the fields userInfo already carried", async () => {
NativeModules.RNPurchases.getCustomerInfo.mockRejectedValueOnce(nativeRejection());

const error: PurchasesError = await Purchases.getCustomerInfo().catch((caught: unknown) => caught);
const userInfo = error.userInfo as Record<string, unknown>;

expect(userInfo.underlyingErrorMessage).toBe("Invalid API Key.");
expect(userInfo.readable_error_code).toBe("InvalidCredentialsError");
});

it("leave successful calls alone", async () => {
NativeModules.RNPurchases.getCustomerInfo.mockResolvedValueOnce(global.customerInfoStub);

await expect(Purchases.getCustomerInfo()).resolves.toBeTruthy();
});
});
6 changes: 4 additions & 2 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1184,7 +1184,8 @@ describe("Purchases", () => {
message: "User cancelled",
readableErrorCode: "USER_CANCELLED",
underlyingErrorMessage: "The user cancelled",
userCancelled: true
userCancelled: true,
userInfo: { readableErrorCode: "USER_CANCELLED" }
});
});

Expand All @@ -1203,7 +1204,8 @@ describe("Purchases", () => {
message: "User cancelled",
readableErrorCode: "USER_CANCELLED",
underlyingErrorMessage: "The user cancelled",
userCancelled: true
userCancelled: true,
userInfo: { readableErrorCode: "USER_CANCELLED" }
});
});

Expand Down
43 changes: 43 additions & 0 deletions __tests__/normalizingRejections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { normalizingRejections } from "../src/normalizingRejections";

// React Native's bridge generates each method as a named function and tags it with the
// call type (NativeModules.js: `fn.type = type`), so the wrapper has to pass both on.
function bridgeMethod(result: () => unknown) {
const fn = function promiseMethodWrapper() {
return result();
};
(fn as typeof fn & { type: string }).type = "promise";
return fn;
}

function moduleWith(result: () => unknown) {
return normalizingRejections({ getCustomerInfo: bridgeMethod(result), addListener: () => undefined });
}

describe("the native module wrapper", () => {
it("keeps what the bridge puts on its methods", () => {
const wrapped = moduleWith(() => Promise.resolve({}));

expect(wrapped.getCustomerInfo.name).toBe("promiseMethodWrapper");
expect((wrapped.getCustomerInfo as unknown as { type: string }).type).toBe("promise");
expect(Reflect.ownKeys(wrapped.getCustomerInfo)).toEqual(
Reflect.ownKeys(bridgeMethod(() => Promise.resolve({}))),
);
});

it("normalizes a rejection", async () => {
const wrapped = moduleWith(() =>
Promise.reject(Object.assign(new Error("nope"), { code: "11", userInfo: { readableErrorCode: "Bad" } })),
);

const error = await wrapped.getCustomerInfo().catch((caught: unknown) => caught);

expect((error as { readableErrorCode: string }).readableErrorCode).toBe("Bad");
});

it("leaves a non-promise return alone", () => {
const wrapped = normalizingRejections({ addListener: bridgeMethod(() => "sync value") });

expect(wrapped.addListener()).toBe("sync value");
});
});
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,6 @@ def kotlin_version = getExtOrDefault('kotlinVersion')
dependencies {
//noinspection GradleDynamicVersion
api 'com.facebook.react:react-native:+'
implementation 'com.revenuecat.purchases:purchases-hybrid-common:19.2.0'
implementation 'com.revenuecat.purchases:purchases-hybrid-common:19.3.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
18 changes: 9 additions & 9 deletions examples/purchaseTesterTypescript/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ PODS:
- hermes-engine (0.78.0):
- hermes-engine/Pre-built (= 0.78.0)
- hermes-engine/Pre-built (0.78.0)
- PurchasesHybridCommon (19.2.0):
- PurchasesHybridCommon (19.3.0):
- RevenueCat (= 5.90.1)
- PurchasesHybridCommonUI (19.2.0):
- PurchasesHybridCommon (= 19.2.0)
- PurchasesHybridCommonUI (19.3.0):
- PurchasesHybridCommon (= 19.3.0)
- RevenueCatUI (= 5.90.1)
- RCT-Folly (2024.11.18.00):
- boost
Expand Down Expand Up @@ -1600,10 +1600,10 @@ PODS:
- RevenueCatUI (5.90.1):
- RevenueCat (= 5.90.1)
- RNPaywalls (10.10.0):
- PurchasesHybridCommonUI (= 19.2.0)
- PurchasesHybridCommonUI (= 19.3.0)
- React-Core
- RNPurchases (10.10.0):
- PurchasesHybridCommon (= 19.2.0)
- PurchasesHybridCommon (= 19.3.0)
- React-Core
- RNScreens (4.11.1):
- DoubleConversion
Expand Down Expand Up @@ -1886,8 +1886,8 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8
hermes-engine: b417d2b2aee3b89b58e63e23a51e02be91dc876d
PurchasesHybridCommon: d3ac1e960252e66c535d5bde6ac6dc7c9716d7eb
PurchasesHybridCommonUI: 10771869c8f36f7963a4538ad97dcecec2e1a0d4
PurchasesHybridCommon: 7df7a6d2e2061bc82f99da8f8328f4b3e31d7f6f
PurchasesHybridCommonUI: a0410475c09d575817904ab71772b597786cd707
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: b2eecf2d60216df56bc5e6be5f063826d3c1ee35
RCTRequired: 78522de7dc73b81f3ed7890d145fa341f5bb32ea
Expand Down Expand Up @@ -1949,8 +1949,8 @@ SPEC CHECKSUMS:
ReactCommon: 0c097b53f03d6bf166edbcd0915da32f3015dd90
RevenueCat: fafa0b7c0c2f6b04333f4a2c81624723c672f38e
RevenueCatUI: 815e33694c4035d12d942653b64c73b4bbad1c85
RNPaywalls: 7b363c1dce1e71969e1472ad11cb82a641db7cbb
RNPurchases: 210f92fbc39501740f52330a06d261a96a92fc99
RNPaywalls: d6d20083de35e7aefab2b4fccdd579578ceacad4
RNPurchases: c9280d16c6ff343c93511618d0bced72f12a5f79
RNScreens: 8aec64fb213fd52a64445e2e470f7a9b546c5df3
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c
Expand Down
50 changes: 23 additions & 27 deletions ios/RNPurchases.m
Original file line number Diff line number Diff line change
Expand Up @@ -324,18 +324,13 @@ static void logUnavailablePresentCodeRedemptionSheet() {
[RCCommonFunctionality eligibleWinBackOffersForProductIdentifier:productID
completionBlock:^(NSArray<NSDictionary *> * _Nullable offers, RCErrorContainer * _Nullable errorContainer) {
if (errorContainer) {
reject(
[NSString stringWithFormat:@"%ld", (long)errorContainer.code],
errorContainer.message,
errorContainer.error
);
[self rejectPromiseWithBlock:reject error:errorContainer];
} else {
resolve(offers ?: @[]);
}
}];
} else {
NSError *error = [self createUnsupportedErrorWithDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
reject([NSString stringWithFormat:@"%ld", (long)error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
}
}

Expand All @@ -348,8 +343,7 @@ static void logUnavailablePresentCodeRedemptionSheet() {
winBackOfferID:winBackOfferID
completionBlock:[self getResponseCompletionBlockWithResolve:resolve reject:reject]];
} else {
NSError *error = [self createUnsupportedErrorWithDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
reject([NSString stringWithFormat:@"%ld", (long)error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
}
}

Expand All @@ -364,8 +358,7 @@ static void logUnavailablePresentCodeRedemptionSheet() {
winBackOfferID:winBackOfferID
completionBlock:[self getResponseCompletionBlockWithResolve:resolve reject:reject]];
} else {
NSError *error = [self createUnsupportedErrorWithDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
reject([NSString stringWithFormat:@"%ld", (long)error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"iOS win-back offers are only available on iOS 18.0 or greater."];
}
}

Expand Down Expand Up @@ -551,24 +544,18 @@ static void logUnavailablePresentCodeRedemptionSheet() {
if (@available(iOS 13.0, macOS 10.15, visionOS 1.0, *)) {
[RCCommonFunctionality showManageSubscriptions:^(RCErrorContainer * _Nullable errorContainer) {
if (errorContainer) {
reject(
[NSString stringWithFormat:@"%ld", (long)errorContainer.code],
errorContainer.message,
errorContainer.error
);
[self rejectPromiseWithBlock:reject error:errorContainer];
} else {
resolve(nil);
}
}];
} else {
NSLog(@"[Purchases] Warning: tried to showManageSubscriptions in non supported iOS devices. Only available on iOS 13.0 or greater.");
NSError *error = [self createUnsupportedErrorWithDescription:@"Tried to present manage subscriptions sheet, but this functionality is only available on iOS 13.0 or greater."];
reject([NSString stringWithFormat:@"%ld", (long)error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"Tried to present manage subscriptions sheet, but this functionality is only available on iOS 13.0 or greater."];
}
#else
NSLog(@"[Purchases] Warning: tried to showManageSubscriptions in non-ios devices. That's not supported.");
NSError *error = [self createUnsupportedErrorWithDescription:@"Tried to present manage subscriptions sheet, but this functionality is only available on iOS devices."];
reject([NSString stringWithFormat:@"%ld", (long)error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"Tried to present manage subscriptions sheet, but this functionality is only available on iOS devices."];
#endif
}

Expand Down Expand Up @@ -657,8 +644,7 @@ static void logUnavailablePresentCodeRedemptionSheet() {
completion:[self getResponseCompletionBlockWithResolve:resolve
reject:reject]];
} else {
NSError *error = [self createUnsupportedErrorWithDescription:@"Tried to handle transaction made by your app, but this functionality is only available on iOS 15.0 or greater."];
reject([NSString stringWithFormat:@"%ld", (long) error.code], [error localizedDescription], error);
[self rejectPromiseWithBlock:reject unsupportedErrorDescription:@"Tried to handle transaction made by your app, but this functionality is only available on iOS 15.0 or greater."];
}
}

Expand Down Expand Up @@ -749,7 +735,15 @@ - (void)purchases:(RCPurchases *)purchases
#pragma mark Helper Methods

- (void)rejectPromiseWithBlock:(RCTPromiseRejectBlock)reject error:(RCErrorContainer *)error {
reject([NSString stringWithFormat:@"%ld", (long) error.code], error.message, error.error);
// React Native forwards only the NSError's userInfo to the JS layer, never the error
// container's info dictionary, so the payload has to travel inside userInfo.
NSMutableDictionary *userInfo = [error.error.userInfo mutableCopy];
[userInfo addEntriesFromDictionary:error.info];
NSError *errorWithInfo = [NSError errorWithDomain:error.error.domain
code:error.error.code
userInfo:userInfo];

reject([NSString stringWithFormat:@"%ld", (long) error.code], error.message, errorWithInfo);
}

- (void (^)(NSDictionary *, RCErrorContainer *))getResponseCompletionBlockWithResolve:(RCTPromiseResolveBlock)resolve
Expand Down Expand Up @@ -778,10 +772,12 @@ - (void)rejectPromiseWithBlock:(RCTPromiseRejectBlock)reject error:(RCErrorConta
};
}

- (NSError *)createUnsupportedErrorWithDescription:(NSString *)description {
return [[NSError alloc] initWithDomain:RCPurchasesErrorCodeDomain
code:RCUnsupportedError
userInfo:@{NSLocalizedDescriptionKey : description}];
- (void)rejectPromiseWithBlock:(RCTPromiseRejectBlock)reject unsupportedErrorDescription:(NSString *)description {
NSError *error = [[NSError alloc] initWithDomain:RCPurchasesErrorCodeDomain
code:RCUnsupportedError
userInfo:@{NSLocalizedDescriptionKey : description}];
[self rejectPromiseWithBlock:reject
error:[[RCErrorContainer alloc] initWithError:error extraPayload:@{}]];
}

- (NSString *)platformFlavor {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
]
},
"dependencies": {
"@revenuecat/purchases-js-hybrid-mappings": "19.2.0",
"@revenuecat/purchases-typescript-internal": "19.2.0"
"@revenuecat/purchases-js-hybrid-mappings": "19.3.0",
"@revenuecat/purchases-typescript-internal": "19.3.0"
}
}
2 changes: 1 addition & 1 deletion react-native-purchases-store-galaxy/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@ dependencies {
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation 'com.revenuecat.purchases:purchases-hybrid-common-store-galaxy:19.2.0'
implementation 'com.revenuecat.purchases:purchases-hybrid-common-store-galaxy:19.3.0'
}
2 changes: 1 addition & 1 deletion react-native-purchases-store-galaxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,6 @@
]
},
"dependencies": {
"@revenuecat/purchases-typescript-internal": "19.2.0"
"@revenuecat/purchases-typescript-internal": "19.3.0"
}
}
2 changes: 1 addition & 1 deletion react-native-purchases-ui/RNPaywalls.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ Pod::Spec.new do |spec|
spec.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }

spec.dependency "React-Core"
spec.dependency "PurchasesHybridCommonUI", '19.2.0'
spec.dependency "PurchasesHybridCommonUI", '19.3.0'
spec.swift_version = '5.7'
end
2 changes: 1 addition & 1 deletion react-native-purchases-ui/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ dependencies {
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'com.revenuecat.purchases:purchases-hybrid-common-ui:19.2.0'
implementation 'com.revenuecat.purchases:purchases-hybrid-common-ui:19.3.0'
implementation 'androidx.compose.ui:ui-android:1.5.4'
implementation "androidx.appcompat:appcompat:1.6.1"
}
2 changes: 1 addition & 1 deletion react-native-purchases-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,6 @@
]
},
"dependencies": {
"@revenuecat/purchases-typescript-internal": "19.2.0"
"@revenuecat/purchases-typescript-internal": "19.3.0"
}
}
26 changes: 26 additions & 0 deletions src/normalizingRejections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { normalizePurchasesError } from "@revenuecat/purchases-typescript-internal";

// The returned promise is chained, never copied: enumerating a TurboModule
// promise's own properties throws inside Hermes.
export function normalizingRejections<T extends object>(module: T): T {
return new Proxy(module, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") {
return value;
}
// An apply trap keeps what the bridge puts on the method it generated, such as its
// name and React Native's own call-type marker.
return new Proxy(value as (...callArgs: unknown[]) => unknown, {
apply: (method, thisArg, args) => {
const result = Reflect.apply(method, thisArg, args);
return result instanceof Promise
? result.catch((error: unknown) => {
throw normalizePurchasesError(error);
})
: result;
},
});
},
});
}
Loading