Skip to content

syncIOS() user-cancel still not detectable — parseErrorStringToJsonObj drops native code (follow-up to #194) #202

Description

@kenn-chen

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

  1. iOS device or simulator with a Sandbox tester.
  2. Sign the tester out of the App Store so AppStore.sync() prompts for credentials.
  3. Call syncIOS() (directly or via restorePurchases()).
  4. 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:

  1. 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.
  2. .message is the NSError text-serialization (Error Domain=… Code=-1 "…JSON…" UserInfo=…), not JSON — JSON.parse throws.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cross-platformCross-platform (both Android & iOS)kmp-iapkmp-iap libraryreact-native-iapreact-native-iap library🐛 bugSomething isn't working📱 iOSRelated to iOS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions