Skip to content

fix(ios): handle promoted iap cold start#144

Merged
hyochan merged 4 commits into
mainfrom
codex/issue-143-promoted-iap-cold-start
May 8, 2026
Merged

fix(ios): handle promoted iap cold start#144
hyochan merged 4 commits into
mainfrom
codex/issue-143-promoted-iap-cold-start

Conversation

@hyochan

@hyochan hyochan commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

  • Register the Apple promoted purchase observer at native module launch so StoreKit purchase intents are captured before JS calls initConnection.
  • Replay pending promoted products through expo-iap listeners and add Expo autolinking support for the AppDelegate subscriber.
  • Keep the Expo example mobile-sized on iPad, add a Promoted IAP example screen, and document the 2.1.8 release plan.

Closes #143

Test plan

  • swift test in packages/apple
  • bun run lint:tsc in libraries/expo-iap
  • bun run build:plugin in libraries/expo-iap
  • bunx tsc -p tsconfig.json --noEmit --skipLibCheck in libraries/expo-iap/example
  • bun run test -- --runTestsByPath __tests__/index.test.tsx __tests__/layout.test.tsx in libraries/expo-iap/example
  • bun run test:plugin in libraries/expo-iap
  • bun run build in packages/docs
  • Physical iPad promoted IAP cold-start test using itms-services://?action=purchaseIntent&bundleId=dev.hyo.martie&productIdentifier=dev.hyo.martie.premium

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Promoted IAP support to capture app store promotion intents at cold start
    • Redesigned example app interface with grid-based menu layout and dynamic accent colors
    • Added event tracking and history for promoted product interactions
  • Bug Fixes

    • Fixed promoted product listener to replay pending promotions on iOS app launch

Register the Apple promoted purchase observer before JS init, replay pending promoted products to Expo listeners, and add the Expo example/docs release note for issue #143.
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Rate limit exceeded

@hyochan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 4 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34d7c7a3-fe77-405d-af74-673fa7dba206

📥 Commits

Reviewing files that changed from the base of the PR and between b791e4e and 5512a48.

📒 Files selected for processing (9)
  • libraries/expo-iap/example/__tests__/index.test.tsx
  • libraries/expo-iap/example/__tests__/layout.test.tsx
  • libraries/expo-iap/example/app/index.tsx
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts
  • packages/apple/Sources/Helpers/IapState.swift
  • packages/apple/Sources/OpenIapModule.swift
  • packages/apple/Tests/OpenIapTests.swift
  • packages/docs/src/pages/docs/updates/releases.tsx
📝 Walkthrough

Walkthrough

This PR implements end-to-end support for promoted in-app purchases on iOS cold start by registering an app delegate subscriber to initialize the native module early, moving payment queue observer registration to module init, updating the listener subscription contract to return pending product IDs, wrapping the listener with deduplication and replay logic, and exposing event tracking through a TypeScript system and demo screen.

Changes

Promoted IAP Cold-Start Fix

Layer / File(s) Summary
iOS Listener Registration Contract
packages/apple/Sources/Helpers/IapState.swift
addPromotedProductListener now returns the current promoted product ID as optional String? to enable callers to detect pending products at subscription time.
iOS Module Lifecycle & Observer Registration
packages/apple/Sources/OpenIapModule.swift
Payment queue observer registration moves from connection init to module init/deinit with atomic locking. promotedProductListenerIOS now schedules listener registration asynchronously, captures pending SKU, and dispatches callbacks on MainActor.
iOS Connection Lifecycle Refactoring
packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift
Removes redundant didRegisterPaymentQueueObserver state tracking since observer registration is now atomic at module level, simplifying connection cleanup.
App Delegate Subscriber for Early Init
libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift
New Swift class that accesses OpenIapModule.shared during app launch (iOS 15+/tvOS 16+) to trigger module initialization and payment queue observer registration before JS code runs.
Module Configuration & Autolinking
libraries/expo-iap/expo-module.config.json, libraries/expo-iap/plugin/src/withIAP.ts
Registers ExpoIapAppDelegateSubscriber in module config and updates Expo plugin autolinking to compute and track both app delegate subscribers with proper change detection.
TypeScript Listener Wrapper
libraries/expo-iap/src/index.ts, libraries/expo-iap/src/__tests__/index.test.ts
Wraps promotedProductListenerIOS to deduplicate product deliveries by product ID, proactively fetches pending promoted product via getPromotedProductIOS() at subscription time, and tolerates fetch errors. Tests verify replay and dedup behavior.
TypeScript Event Tracking System
libraries/expo-iap/example/src/promotedIapEvents.ts
New module providing in-memory event buffering (max 25 entries), lifecycle controls (registerPromotedIapEvents, resetPromotedIapEvents), buildPromotedIapUrl for purchase intent links, async refreshPromotedIapProduct, and usePromotedIapEvents hook via useSyncExternalStore.
Example App Integration
libraries/expo-iap/example/app/_layout.tsx, libraries/expo-iap/example/app/promoted-iap.tsx
Adds promoted-iap route to Expo Router, calls registerPromotedIapEvents in useEffect at app mount, and implements demo screen with product selection, purchase intent URL generation, copy/open actions, and event display.
Example Home UI Refactor
libraries/expo-iap/example/app/index.tsx
Changes Home from FlatList to ScrollView grid, adds accentColor per menu item for icon background styling, removes emoji prefixes from labels, and refactors styles for new layout.
Example Config & Test Updates
libraries/expo-iap/example/app.config.ts, libraries/expo-iap/example/__tests__/*
Disables tablet support in config, updates Home/RootLayout tests to use async waitFor assertions, removes emoji from expected menu labels, mocks promotedIapEvents, and verifies promoted-iap route.
Native Tests & Documentation
packages/apple/Tests/OpenIapTests.swift, packages/docs/src/pages/docs/updates/releases.tsx
Adds XCTest verifying addPromotedProductListener returns pending product ID, documents cold-start fix in May 8, 2026 release note with feature summary and all related package versions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • hyodotdev/openiap#105: Modifies the same Expo IAP module listener wiring and autolinking configuration.
  • hyodotdev/openiap#142: Updates the same Apple-side connection lifecycle pieces (IapState, OpenIapConnectionLifecycle, OpenIapModule) for payment queue observer registration.
  • hyodotdev/openiap#90: Updates the same release notes documentation file with Releases page entry.

Suggested labels

📱 iOS, 🛠 bugfix, expo-iap

Poem

🐰 A rabbit hops through the cold startup dawn,
Where promoted products were lost and now reborn!
With app delegates early and listeners replay,
The products arrive before JS has its say.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from issue #143: registering the payment queue observer at module initialization to capture promoted products before JS loads, replaying pending products through listeners, and adding necessary infrastructure for cold-start handling across multiple files.
Out of Scope Changes check ✅ Passed All changes directly support the core objective of fixing promoted IAP cold start. The example app updates (UI refactor, promoted-iap screen), documentation updates, and test additions are all within scope as supporting materials for the bug fix.
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a cold-start bug for promoted IAP on iOS by ensuring the observer is registered at native module launch.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/issue-143-promoted-iap-cold-start

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@hyochan hyochan added expo-iap expo-iap library 📖 documentation Improvements or additions to documentation 📱 iOS Related to iOS 🛠 bugfix All kinds of bug fixes labels May 8, 2026
@hyochan hyochan changed the title fix: handle promoted iap cold start fix(ios): handle promoted iap cold start May 8, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements a fix for iOS Promoted IAP cold-start issues by moving the StoreKit payment queue observer registration to native module launch and decoupling it from the connection lifecycle. It introduces a new ExpoIapAppDelegateSubscriber for Expo projects and updates the library to replay pending promoted products to listeners. The example app has been updated with a dedicated Promoted IAP test screen and a refreshed home screen UI. Feedback highlights a type mismatch in promotedProductListenerIOS where the native SKU string is not correctly handled as a Product object, causing deduplication to fail. Additionally, a React key placement issue was identified in the example app's menu grid rendering.

Comment thread libraries/expo-iap/src/index.ts
Comment thread libraries/expo-iap/src/__tests__/index.test.ts
Comment thread libraries/expo-iap/example/app/index.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
libraries/expo-iap/src/index.ts (1)

230-236: ⚡ Quick win

Replace .then().catch() with an async IIFE to comply with the coding guidelines.

Both the general **/*.{ts,tsx,js,jsx} and libraries/expo-iap/** guidelines require async/await instead of Promise chains.

♻️ Proposed refactor
-  void Promise.resolve(ExpoIapModule.getPromotedProductIOS())
-    .then((product: Product | null) => {
-      if (product) {
-        deliver(product);
-      }
-    })
-    .catch(() => {});
+  void (async () => {
+    try {
+      const product = await ExpoIapModule.getPromotedProductIOS();
+      if (product) {
+        deliver(product);
+      }
+    } catch {
+      // cold-start fetch is best-effort; errors are intentionally suppressed
+    }
+  })();

As per coding guidelines: "Always use async/await for handling promises instead of .then() chains" (**/*.{ts,tsx,js,jsx}) and "Use async/await instead of Promise chains for better readability" (libraries/expo-iap/**).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/expo-iap/src/index.ts` around lines 230 - 236, Replace the Promise
chain calling ExpoIapModule.getPromotedProductIOS() with an async IIFE that
awaits the call and uses try/catch; specifically, create an immediately-invoked
async function that calls await ExpoIapModule.getPromotedProductIOS(), checks
the returned Product (same null check as before) and calls deliver(product) if
present, and move the empty .catch() logic into the catch block of the IIFE to
handle errors consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/expo-iap/example/app/promoted-iap.tsx`:
- Around line 33-39: The functions copyUrl and openUrl currently await
Clipboard.setStringAsync and Linking.openURL without error handling; wrap each
function body in try/catch (for copyUrl and openUrl) and handle errors
explicitly (e.g., process with console.error or show an Alert/Toast to the user)
so promise rejections are not unhandled; ensure the catch logs the error and
provides a user-friendly fallback message, and keep the function signatures
(copyUrl, openUrl) intact so they can still be used as onPress handlers.

In `@packages/apple/Sources/Helpers/IapState.swift`:
- Around line 44-46: The current addPromotedProductListener((_ pair: (UUID,
PromotedProductListener))) returns promotedProductId which races with the async
emitPromotedProduct path and can double-deliver; change
addPromotedProductListener so it performs listener registration and
pending-event consumption atomically: when adding the listener
(promotedProductListeners.append), immediately capture and clear
promotedProductId (set it to nil) within the same actor-synchronized operation
and either (a) do NOT return the SKU (return nil) and let emitPromotedProduct
deliver it once, or (b) if you must deliver synchronously, invoke the listener
directly with the captured SKU before returning and ensure promotedProductId is
cleared so emitPromotedProduct won’t re-emit; reference
addPromotedProductListener, promotedProductListeners, promotedProductId,
emitPromotedProduct, and shouldAddStorePayment when making the change.

In `@packages/apple/Sources/OpenIapModule.swift`:
- Around line 1463-1509: The helpers currently set
didRegisterPromotedPurchaseObserver before the SKPaymentQueue add/remove
actually runs asynchronously; change them to perform the add/remove
synchronously on the main thread first, then update
didRegisterPromotedPurchaseObserver under promotedPurchaseObserverLock so the
flag reflects the real state. Concretely, in
registerPromotedPurchaseObserverIfNeeded and
unregisterPromotedPurchaseObserverIfNeeded: check the current flag under
promotedPurchaseObserverLock to decide work, then if work is needed perform the
SKPaymentQueue.default().add(self) or .remove(self) synchronously on the main
thread (use Thread.isMainThread ? call directly : DispatchQueue.main.sync),
avoid an async closure with a weak self for the registration/removal so the call
definitely executes before returning, and finally acquire the lock to set
didRegisterPromotedPurchaseObserver = true/false.

In `@packages/docs/src/pages/docs/updates/releases.tsx`:
- Around line 45-69: The release notes currently assert "Publishes openiap-apple
2.1.8" and use final-release wording/links, which will create dead links if
artifacts/tags aren't yet published; change the copy for "openiap-apple 2.1.8",
the surrounding paragraph that references promotedProductListenerIOS and
getPromotedProductIOS(), and any links pointing to the final release tag
(including the GitHub issue link if it targets a release tag) to planned-release
wording (e.g., "Planned: openiap-apple 2.1.8 — will publish..." or "This release
will...") or point links to the PR/draft notes instead, and apply the same
tense/link fixes to the other block referenced (lines around the 118-179
section).

---

Nitpick comments:
In `@libraries/expo-iap/src/index.ts`:
- Around line 230-236: Replace the Promise chain calling
ExpoIapModule.getPromotedProductIOS() with an async IIFE that awaits the call
and uses try/catch; specifically, create an immediately-invoked async function
that calls await ExpoIapModule.getPromotedProductIOS(), checks the returned
Product (same null check as before) and calls deliver(product) if present, and
move the empty .catch() logic into the catch block of the IIFE to handle errors
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77220443-97b9-493b-8fa6-04604413251b

📥 Commits

Reviewing files that changed from the base of the PR and between 2d79bae and b791e4e.

📒 Files selected for processing (17)
  • libraries/expo-iap/example/__tests__/index.test.tsx
  • libraries/expo-iap/example/__tests__/layout.test.tsx
  • libraries/expo-iap/example/app.config.ts
  • libraries/expo-iap/example/app/_layout.tsx
  • libraries/expo-iap/example/app/index.tsx
  • libraries/expo-iap/example/app/promoted-iap.tsx
  • libraries/expo-iap/example/src/promotedIapEvents.ts
  • libraries/expo-iap/expo-module.config.json
  • libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift
  • libraries/expo-iap/plugin/src/withIAP.ts
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts
  • packages/apple/Sources/Helpers/IapState.swift
  • packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift
  • packages/apple/Sources/OpenIapModule.swift
  • packages/apple/Tests/OpenIapTests.swift
  • packages/docs/src/pages/docs/updates/releases.tsx

Comment thread libraries/expo-iap/example/app/promoted-iap.tsx Outdated
Comment thread packages/apple/Sources/Helpers/IapState.swift Outdated
Comment thread packages/apple/Sources/OpenIapModule.swift
Comment thread packages/docs/src/pages/docs/updates/releases.tsx Outdated
@hyochan hyochan merged commit 1cee303 into main May 8, 2026
10 checks passed
@hyochan hyochan deleted the codex/issue-143-promoted-iap-cold-start branch May 8, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠 bugfix All kinds of bug fixes 📖 documentation Improvements or additions to documentation expo-iap expo-iap library 📱 iOS Related to iOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

promotedProductListenerIOS and getPromotedProductIOS both fail to fire on cold start

1 participant