Summary
Follow-up to #194 / #195. The Swift side (openiap-apple 2.2.3) now correctly emits {"code":"user-cancelled", …} from syncIOS() on Apple ID prompt cancel — verified in the raw error payload. But the JS wrapper's parseErrorStringToJsonObj at libraries/react-native-iap/src/utils/error.ts:30-77 can't extract that structured code from the Nitro NSError toString shape, so the resulting PurchaseError.code is "unknown" and isUserCancelledError(err) returns false.
Reproduce
- iOS device or simulator with a Sandbox tester.
- Sign the tester out of the App Store so
AppStore.sync() prompts for credentials.
- Call
syncIOS() (directly or via restorePurchases()).
- Tap Cancel on the Apple ID password prompt.
Expected
err.code === ErrorCode.UserCancelled → isUserCancelledError(err) === true. The Swift fix now emits the right payload; the JS side should surface it.
Actual
err.code === "unknown" → isUserCancelledError(err) === false.
The raw error the app sees:
Error Domain=com.margelo.nitro.rniap Code=-1
"{"message":"Request Canceled","code":"user-cancelled"}"
UserInfo={NSLocalizedDescription={"message":"Request Canceled","code":"user-cancelled"}}
The structured "code":"user-cancelled" is present but never reaches the surfaced PurchaseError.code.
Root cause
libraries/react-native-iap/src/utils/error.ts:30-77 (v15.3.4):
export function parseErrorStringToJsonObj(errorString: string | Error | unknown): IapError {
// Handle Error objects
if (errorString instanceof Error) {
errorString = errorString.message; // (1) drops any .code on the Error object
}
...
// Try to parse as JSON first
try {
const parsed = JSON.parse(errorString); // (2) fails: outer text is NSError, not JSON
...
} catch { /* Not JSON, continue */ }
// Try to parse "CODE: message" format
const colonIndex = errorString.indexOf(':');
if (colonIndex > 0 && colonIndex < 50) { // (3) fails: first colon lives inside the
... // embedded JSON at position ~56
}
// Fallback: treat entire string as message
return { code: ErrorCode.Unknown, message: errorString };
}
Three consecutive misses:
- The Nitro-thrown
Error may already have a structured .code set from OpenIapException.make(code:message:) on the Swift side. The parser immediately discards everything except .message.
.message is the NSError text-serialization (Error Domain=… Code=-1 "…JSON…" UserInfo=…), not JSON — JSON.parse throws.
- The
"CODE: message" heuristic requires the first colon before position 50; the first : here is inside the embedded JSON at ~position 56, so this branch never fires.
Final fallback returns code: ErrorCode.Unknown, which syncIOS()'s wrapper at src/index.ts:2234-2240 then packages into a PurchaseError. This affects every other parseErrorStringToJsonObj caller on the Nitro path too (there are 8+ in src/index.ts), not just syncIOS.
Suggested fix
Extract the embedded JSON payload from Nitro's NSError text-serialization before falling back to the existing heuristics. Verified locally by patching node_modules — after the patch, isUserCancelledError(err) returns true on cancel and downstream consumers see the correct code end-to-end.
export function parseErrorStringToJsonObj(errorString: string | Error | unknown): IapError {
if (errorString instanceof Error) {
// Nitro's NSError toString has the shape:
// `Error Domain=... Code=-1 "<JSON payload>" UserInfo=...`
// The existing JSON.parse and "CODE: message" branches both miss it, so
// pull the payload out by its markers and JSON-parse it directly.
const startMarker = 'Code=-1 "';
const endMarker = '" UserInfo=';
const start = errorString.message.indexOf(startMarker);
if (start >= 0) {
const contentStart = start + startMarker.length;
const end = errorString.message.indexOf(endMarker, contentStart);
if (end > contentStart) {
try {
const parsed = JSON.parse(errorString.message.substring(contentStart, end));
if (parsed && typeof parsed.code === "string" && parsed.code.length > 0) {
return { code: parsed.code, message: parsed.message ?? errorString.message };
}
} catch {
// Fall through to the existing parsers below.
}
}
}
errorString = errorString.message;
}
...
}
This branch is deterministic against the current Nitro NSError shape — any change in Nitro's toString formatting would break it, but for now it's the only signal available in the JS wrapper.
Cheap forward-proofing worth adding alongside: also check for a structured .code property on the incoming Error before dropping to .message. React-native-nitro-modules v0.35.4 doesn't attach one (verified — my err.code was undefined), so this branch is a no-op today, but if a future Nitro release starts propagating it, this preserves the code without needing the parser re-patched.
if (typeof (errorString as Error & { code?: unknown }).code === "string") {
return { code: (errorString as { code: string }).code, message: errorString.message };
}
Cancellation is being logged at error level
Adjacent bug, cheap to fix in the same PR. Every parseErrorStringToJsonObj caller in src/index.ts follows the same shape:
} catch (error) {
RnIapConsole.error('[syncIOS] Failed:', error); // ← unconditional error log
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({ ... });
}
RnIapConsole.error always fires (per utils/debug.ts:38-41, errors are unconditionally passed through — no dev flag). Once #194 landed and cancels are typed correctly, this logs a user's Cancel tap as [syncIOS] Failed: — semantically wrong, and any consumer with Sentry/Bugsnag/Datadog wired into console.error treats a normal user action as a production issue.
Suggested shape once the parser fix above is in:
} catch (error) {
const parsedError = parseErrorStringToJsonObj(error);
if (parsedError.code !== ErrorCode.UserCancelled) {
RnIapConsole.error('[syncIOS] Failed:', error);
}
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage,
});
}
Same treatment applies to every native-path catch block in src/index.ts (the same 8+ sites that call parseErrorStringToJsonObj).
Related
Environment
|
|
react-native-iap |
15.3.4 (pinning openiap-apple 2.2.3, includes #195) |
react-native-nitro-modules |
0.35.4 |
react-native |
0.85.3 |
| Expo SDK |
56.0.11 |
| Platform |
iOS |
Summary
Follow-up to #194 / #195. The Swift side (openiap-apple 2.2.3) now correctly emits
{"code":"user-cancelled", …}fromsyncIOS()on Apple ID prompt cancel — verified in the raw error payload. But the JS wrapper'sparseErrorStringToJsonObjatlibraries/react-native-iap/src/utils/error.ts:30-77can't extract that structured code from the Nitro NSError toString shape, so the resultingPurchaseError.codeis"unknown"andisUserCancelledError(err)returnsfalse.Reproduce
AppStore.sync()prompts for credentials.syncIOS()(directly or viarestorePurchases()).Expected
err.code === ErrorCode.UserCancelled→isUserCancelledError(err) === true. The Swift fix now emits the right payload; the JS side should surface it.Actual
err.code === "unknown"→isUserCancelledError(err) === false.The raw error the app sees:
The structured
"code":"user-cancelled"is present but never reaches the surfacedPurchaseError.code.Root cause
libraries/react-native-iap/src/utils/error.ts:30-77(v15.3.4):Three consecutive misses:
Errormay already have a structured.codeset fromOpenIapException.make(code:message:)on the Swift side. The parser immediately discards everything except.message..messageis the NSError text-serialization (Error Domain=… Code=-1 "…JSON…" UserInfo=…), not JSON —JSON.parsethrows."CODE: message"heuristic requires the first colon before position 50; the first:here is inside the embedded JSON at ~position 56, so this branch never fires.Final fallback returns
code: ErrorCode.Unknown, whichsyncIOS()'s wrapper atsrc/index.ts:2234-2240then packages into aPurchaseError. This affects every otherparseErrorStringToJsonObjcaller on the Nitro path too (there are 8+ insrc/index.ts), not justsyncIOS.Suggested fix
Extract the embedded JSON payload from Nitro's NSError text-serialization before falling back to the existing heuristics. Verified locally by patching
node_modules— after the patch,isUserCancelledError(err)returnstrueon cancel and downstream consumers see the correct code end-to-end.This branch is deterministic against the current Nitro NSError shape — any change in Nitro's
toStringformatting would break it, but for now it's the only signal available in the JS wrapper.Cheap forward-proofing worth adding alongside: also check for a structured
.codeproperty on the incomingErrorbefore dropping to.message. React-native-nitro-modules v0.35.4 doesn't attach one (verified — myerr.codewasundefined), so this branch is a no-op today, but if a future Nitro release starts propagating it, this preserves the code without needing the parser re-patched.Cancellation is being logged at error level
Adjacent bug, cheap to fix in the same PR. Every
parseErrorStringToJsonObjcaller insrc/index.tsfollows the same shape:RnIapConsole.erroralways fires (perutils/debug.ts:38-41, errors are unconditionally passed through — no dev flag). Once #194 landed and cancels are typed correctly, this logs a user's Cancel tap as[syncIOS] Failed:— semantically wrong, and any consumer with Sentry/Bugsnag/Datadog wired intoconsole.errortreats a normal user action as a production issue.Suggested shape once the parser fix above is in:
Same treatment applies to every native-path catch block in
src/index.ts(the same 8+ sites that callparseErrorStringToJsonObj).Related
Environment
react-native-iapreact-native-nitro-modulesreact-native