Skip to content

Expose restorePurchases() in the Expo bridge#183

Open
anglinb wants to merge 2 commits intomainfrom
feat/expose-restore-purchases
Open

Expose restorePurchases() in the Expo bridge#183
anglinb wants to merge 2 commits intomainfrom
feat/expose-restore-purchases

Conversation

@anglinb
Copy link
Contributor

@anglinb anglinb commented Mar 17, 2026

Summary

  • Exposes restorePurchases() across iOS, Android, and TypeScript layers, allowing programmatic purchase restoration from JavaScript (e.g., a "Restore Purchases" button in app settings)
  • Adds RestorationResult type as a discriminated union (restored | failed) to the TypeScript type system
  • Adds the method to both the hooks SDK (useSuperwall) and the compat SDK (Superwall class)

Test plan

  • Verify iOS build compiles with the new AsyncFunction and toJson() extension
  • Verify Android build compiles with the new AsyncFunction and restorationResultToJson()
  • Call restorePurchases() from JS and confirm it returns { result: "restored" } on success
  • Call restorePurchases() from JS and confirm it returns { result: "failed", errorMessage: "..." } on failure
  • Verify compat SDK Superwall.restorePurchases() works with awaitConfig() guard

🤖 Generated with Claude Code

Greptile Summary

This PR exposes restorePurchases() across iOS, Android, and TypeScript, adding a RestorationResult discriminated union type and wiring it into both the hooks SDK (useSuperwall) and the compat SDK (Superwall class). The native implementations are straightforward and follow established patterns in the module.

Key concerns found during review:

  • RestorationResult naming collision (P1): src/SuperwallExpoModule.types.ts introduces a new RestorationResult discriminated union type that is now publicly exported from src/index.ts via export type *. However, src/compat/lib/RestorationResult.ts already exports an abstract RestorationResult class (with Restored/Failed subclasses) from src/compat/index.ts. These two types have the same name but different structures — notably, Failed.toJson() in the compat class returns errorMessage: null, while the new type declares errorMessage: string. This risks confusing consumers who try to type-annotate Superwall.restorePurchases() results using the compat-exported class.
  • Android platform inconsistency (P1): The Android bridge rejects the promise when restorePurchases() returns Result.failure(exception), whereas iOS always resolves. The TypeScript signature Promise<RestorationResult> implies the promise always resolves on both platforms, making this silent divergence a potential source of unhandled rejections on Android.
  • Undiscoverable return type in compat SDK (P2): The RestorationResultJson alias used as the compat restorePurchases() return type is not re-exported from the compat entry point, making it difficult for compat SDK consumers to type-annotate the resolved value.

Confidence Score: 3/5

  • Mostly safe to merge with minor risk — the naming collision is a public API concern and the Android rejection path is a cross-platform inconsistency, but neither causes a crash in the common case.
  • Native implementations are correct and follow existing module patterns. The two flagged P1 issues are real but unlikely to cause immediate runtime failures in typical usage (the Android Result.failure path is an edge case, and the type naming collision is a DX/discoverability issue rather than a runtime bug). Resolving these before wider release is recommended.
  • src/SuperwallExpoModule.types.ts (naming collision) and android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt (promise rejection vs resolution inconsistency with iOS).

Important Files Changed

Filename Overview
src/SuperwallExpoModule.types.ts Adds new RestorationResult discriminated union type — creates a naming collision with the existing abstract RestorationResult class exported from src/compat/lib/RestorationResult.ts, and the two types have structurally different shapes for the failed case.
android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt Adds restorePurchases AsyncFunction using .fold() on the SDK result; the failure branch rejects the promise rather than resolving with a failed shape, creating a platform behavioral inconsistency with iOS.
android/src/main/java/expo/modules/superwallexpo/json/RestorationResult.kt Adds restorationResultToJson helper — correct and symmetric with restorationResultFromJson.
ios/Json/RestorationResult+Json.swift Adds toJson() extension on RestorationResult — clean and consistent with the existing fromJson static method.
ios/SuperwallExpoModule.swift Adds restorePurchases AsyncFunction wrapping Superwall.shared.restorePurchases() in a Swift Task; always resolves the promise, matching the TypeScript contract.
src/SuperwallExpoModule.ts Adds restorePurchases(): Promise<RestorationResult> declaration to the native module interface — straightforward and correctly typed.
src/compat/index.ts Adds restorePurchases() to the compat Superwall class with awaitConfig() guard; however RestorationResultJson is an unexported alias making the return type undiscoverable for compat SDK consumers.
src/useSuperwall.ts Adds restorePurchases to SuperwallStore interface and implementation — consistent with other store methods in pattern (no awaitConfig(), directly delegating to the native module).

Sequence Diagram

sequenceDiagram
    participant JS as JavaScript (useSuperwall / Superwall class)
    participant Bridge as Expo Native Bridge
    participant iOS as iOS (SuperwallKit)
    participant Android as Android (Superwall SDK)

    JS->>Bridge: restorePurchases()
    alt iOS
        Bridge->>iOS: Superwall.shared.restorePurchases()
        iOS-->>Bridge: RestorationResult (.restored | .failed)
        Bridge->>Bridge: result.toJson()
        Bridge-->>JS: Promise.resolve({ result: "restored" | "failed", errorMessage? })
    else Android
        Bridge->>Android: Superwall.instance.restorePurchases()
        Android-->>Bridge: Result<RestorationResult>
        alt Result.success
            Bridge->>Bridge: restorationResultToJson(result)
            Bridge-->>JS: Promise.resolve({ result: "restored" | "failed", errorMessage? })
        else Result.failure (unexpected exception)
            Bridge-->>JS: Promise.reject(CodedException)
        end
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/SuperwallExpoModule.types.ts
Line: 2336-2343

Comment:
**Naming collision with existing `RestorationResult` class**

There is already an exported `RestorationResult` abstract class in `src/compat/lib/RestorationResult.ts` (re-exported from `src/compat/index.ts`). This new discriminated union type has the same name but a structurally different shape — notably the compat class's `Failed.toJson()` returns `errorMessage: null` when no error is present, whereas this new type declares `errorMessage: string` (never null).

Because `src/index.ts` uses `export type * from "./SuperwallExpoModule.types"`, this discriminated union will now be publicly exported from the main package entry point under the same name as the compat class. Consumers of the compat SDK who call `Superwall.restorePurchases()` will want to annotate its `Promise<RestorationResultJson>` return, but the most naturally discoverable `RestorationResult` in scope (from the compat path) is the abstract class — leading to a type mismatch. Consider naming this something distinct like `RestorationResultRaw` or `RestorationResultData` to avoid the collision.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt
Line: 421-433

Comment:
**Platform inconsistency: Android can reject while iOS always resolves**

On iOS, `restorePurchases()` returns a `RestorationResult` value directly; the promise always resolves (with `{ result: "restored" }` or `{ result: "failed", ... }`). On Android, the `.fold()` failure branch calls `promise.reject(CodedException(error))`, meaning if the native SDK wraps an unexpected exception in `Result.failure(...)`, the JS promise rejects rather than resolves.

The TypeScript return type `Promise<RestorationResult>` implies the promise always resolves on both platforms. A caller who writes:

```kotlin
const result = await superwall.restorePurchases()
// assumes result.result === "restored" | "failed"
```

…will see an unhandled exception on Android in unexpected failure scenarios rather than a `{ result: "failed" }` object.

Consider aligning platforms so that any Android-side exception is converted into a `RestorationResult.Failed` shape (resolved, not rejected), mirroring iOS's behavior.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/compat/index.ts
Line: 25

Comment:
**`RestorationResultJson` alias not re-exported from compat**

`RestorationResultJson` is used as the return type of the compat `restorePurchases()` method but is only an internal import alias — it is not exported from the compat entry point. A consumer of `Superwall.restorePurchases()` who needs to annotate the resolved type cannot conveniently import it from the compat path.

Since the same `RestorationResult` name is already taken in this module by the abstract class, either:
1. Export it under a distinct name (e.g., `export type { RestorationResult as RestorationResultResponse } from "../SuperwallExpoModule.types"`), or
2. Resolve the naming collision described in the `SuperwallExpoModule.types.ts` comment and re-export the type here.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "Expose restorePurcha..."

Greptile also left 3 inline comments on this PR.

Adds the ability to programmatically restore purchases from JavaScript,
enabling use cases like a "Restore Purchases" button in app settings
outside of the paywall context.

Changes:
- iOS: Add toJson() to RestorationResult and AsyncFunction bridge
- Android: Add restorationResultToJson() and AsyncFunction bridge
- TypeScript: Add RestorationResult type, module declaration, store method
- Compat: Add restorePurchases() to legacy Superwall class

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 17, 2026

Open in StackBlitz

npm i https://pkg.pr.new/expo-superwall@183

commit: a12eb03

Comment on lines +2336 to +2343
/**
* Represents the result of a purchase restoration attempt.
* - `restored`: The restoration completed successfully.
* - `failed`: The restoration failed, with an accompanying error message.
*/
export type RestorationResult =
| { result: "restored" }
| { result: "failed"; errorMessage: string }
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Naming collision with existing RestorationResult class

There is already an exported RestorationResult abstract class in src/compat/lib/RestorationResult.ts (re-exported from src/compat/index.ts). This new discriminated union type has the same name but a structurally different shape — notably the compat class's Failed.toJson() returns errorMessage: null when no error is present, whereas this new type declares errorMessage: string (never null).

Because src/index.ts uses export type * from "./SuperwallExpoModule.types", this discriminated union will now be publicly exported from the main package entry point under the same name as the compat class. Consumers of the compat SDK who call Superwall.restorePurchases() will want to annotate its Promise<RestorationResultJson> return, but the most naturally discoverable RestorationResult in scope (from the compat path) is the abstract class — leading to a type mismatch. Consider naming this something distinct like RestorationResultRaw or RestorationResultData to avoid the collision.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/SuperwallExpoModule.types.ts
Line: 2336-2343

Comment:
**Naming collision with existing `RestorationResult` class**

There is already an exported `RestorationResult` abstract class in `src/compat/lib/RestorationResult.ts` (re-exported from `src/compat/index.ts`). This new discriminated union type has the same name but a structurally different shape — notably the compat class's `Failed.toJson()` returns `errorMessage: null` when no error is present, whereas this new type declares `errorMessage: string` (never null).

Because `src/index.ts` uses `export type * from "./SuperwallExpoModule.types"`, this discriminated union will now be publicly exported from the main package entry point under the same name as the compat class. Consumers of the compat SDK who call `Superwall.restorePurchases()` will want to annotate its `Promise<RestorationResultJson>` return, but the most naturally discoverable `RestorationResult` in scope (from the compat path) is the abstract class — leading to a type mismatch. Consider naming this something distinct like `RestorationResultRaw` or `RestorationResultData` to avoid the collision.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +421 to +433
AsyncFunction("restorePurchases") { promise: Promise ->
ioScope.launch {
Superwall.instance.restorePurchases().fold({ result ->
scope.launch {
promise.resolve(restorationResultToJson(result))
}
}, { error ->
scope.launch {
promise.reject(CodedException(error))
}
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Platform inconsistency: Android can reject while iOS always resolves

On iOS, restorePurchases() returns a RestorationResult value directly; the promise always resolves (with { result: "restored" } or { result: "failed", ... }). On Android, the .fold() failure branch calls promise.reject(CodedException(error)), meaning if the native SDK wraps an unexpected exception in Result.failure(...), the JS promise rejects rather than resolves.

The TypeScript return type Promise<RestorationResult> implies the promise always resolves on both platforms. A caller who writes:

const result = await superwall.restorePurchases()
// assumes result.result === "restored" | "failed"

…will see an unhandled exception on Android in unexpected failure scenarios rather than a { result: "failed" } object.

Consider aligning platforms so that any Android-side exception is converted into a RestorationResult.Failed shape (resolved, not rejected), mirroring iOS's behavior.

Prompt To Fix With AI
This is a comment left during a code review.
Path: android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt
Line: 421-433

Comment:
**Platform inconsistency: Android can reject while iOS always resolves**

On iOS, `restorePurchases()` returns a `RestorationResult` value directly; the promise always resolves (with `{ result: "restored" }` or `{ result: "failed", ... }`). On Android, the `.fold()` failure branch calls `promise.reject(CodedException(error))`, meaning if the native SDK wraps an unexpected exception in `Result.failure(...)`, the JS promise rejects rather than resolves.

The TypeScript return type `Promise<RestorationResult>` implies the promise always resolves on both platforms. A caller who writes:

```kotlin
const result = await superwall.restorePurchases()
// assumes result.result === "restored" | "failed"
```

…will see an unhandled exception on Android in unexpected failure scenarios rather than a `{ result: "failed" }` object.

Consider aligning platforms so that any Android-side exception is converted into a `RestorationResult.Failed` shape (resolved, not rejected), mirroring iOS's behavior.

How can I resolve this? If you propose a fix, please make it concise.

import { EventEmitter } from "expo"
import { version } from "../../package.json"
import SuperwallExpoModule from "../SuperwallExpoModule"
import type { RestorationResult as RestorationResultJson } from "../SuperwallExpoModule.types"
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 RestorationResultJson alias not re-exported from compat

RestorationResultJson is used as the return type of the compat restorePurchases() method but is only an internal import alias — it is not exported from the compat entry point. A consumer of Superwall.restorePurchases() who needs to annotate the resolved type cannot conveniently import it from the compat path.

Since the same RestorationResult name is already taken in this module by the abstract class, either:

  1. Export it under a distinct name (e.g., export type { RestorationResult as RestorationResultResponse } from "../SuperwallExpoModule.types"), or
  2. Resolve the naming collision described in the SuperwallExpoModule.types.ts comment and re-export the type here.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/compat/index.ts
Line: 25

Comment:
**`RestorationResultJson` alias not re-exported from compat**

`RestorationResultJson` is used as the return type of the compat `restorePurchases()` method but is only an internal import alias — it is not exported from the compat entry point. A consumer of `Superwall.restorePurchases()` who needs to annotate the resolved type cannot conveniently import it from the compat path.

Since the same `RestorationResult` name is already taken in this module by the abstract class, either:
1. Export it under a distinct name (e.g., `export type { RestorationResult as RestorationResultResponse } from "../SuperwallExpoModule.types"`), or
2. Resolve the naming collision described in the `SuperwallExpoModule.types.ts` comment and re-export the type here.

How can I resolve this? If you propose a fix, please make it concise.

- Rename RestorationResult → RestorationResultResponse in types to avoid
  naming collision with the compat RestorationResult class
- Allow errorMessage to be string | null to match compat Failed.toJson()
- Android: resolve with RestorationResult.Failed instead of rejecting the
  promise, aligning behavior with iOS (promise always resolves)
- Re-export RestorationResultResponse from compat entry point so consumers
  can import the type directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@anglinb
Copy link
Contributor Author

anglinb commented Mar 17, 2026

Addressed all three review comments in a12eb03:

  1. Naming collision — Renamed RestorationResultRestorationResultResponse in SuperwallExpoModule.types.ts to avoid collision with the compat RestorationResult class. Also updated errorMessage to string | null to match the compat Failed.toJson() shape.

  2. Android platform inconsistency — The .fold() failure branch now resolves with RestorationResult.Failed(error) instead of rejecting the promise, so it always resolves on both platforms — matching iOS behavior.

  3. Compat re-exportRestorationResultResponse is now re-exported from the compat entry point so consumers can import it directly from expo-superwall/compat.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant