Skip to content

feat: Custom Screens (BYOS) support for React Native - #260

Open
chouaibMo wants to merge 77 commits into
mainfrom
docs/custom-screens-byos-plan
Open

feat: Custom Screens (BYOS) support for React Native#260
chouaibMo wants to merge 77 commits into
mainfrom
docs/custom-screens-byos-plan

Conversation

@chouaibMo

@chouaibMo chouaibMo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Custom Screens (BYOS) for React Native

Lets an app render its own React Native screen as one step inside a Purchasely native flow — Purchasely still owns the flow (transitions, ordering, navigation); the app owns just that one screen. Mirrors the shipped native Android/iOS Custom Screen feature.

Base: feat/sdk-v6-migration · Tickets: MOB-204 (parent MOB-200).

How it works (the reviewer's mental model)

  1. The app registers a component once — AppRegistry.registerComponent('PurchaselyCustomScreen', …) — and tells the SDK its name: setCustomScreenProvider({ componentName }).
  2. Only that string name crosses the bridge; the native module registers a provider with the SDK.
  3. When a flow reaches a client-authored (is_client) step, the SDK calls the provider back. The module packages the presentation into a Bundle, mints a customScreenId, and returns a Fragment (Android) / UIViewController (iOS).
  4. That host mounts a root view / surface on the app's already-running React engine (same JS runtime → shared app state) and renders the named component with the presentation as initialProps.
  5. The screen navigates by calling executeConnection / customScreenBack / customScreenClose; the module resolves the presentation by customScreenId (or requestId for the standalone path) and drives the native flow.

Net effect: React never sends a "component" over the bridge — it sends a name, and each platform's host turns that name back into rendered UI inside the flow's own container.

Where to look

  • Wire contract / JS API: src/customScreen.ts, src/presentationTypes.ts, src/presentation.ts
  • Android host: PurchaselyCustomScreenFragment.kt; provider + connection methods in PurchaselyModule.kt
  • iOS host: delegate + PLYRNCustomScreenViewController + methods in PurchaselyRN.m
  • Highest-risk axis: root-view hosting across old-arch / new-arch (bridgeless) — needs the arch matrix.

Status

Multi-agent code review run — no P0/P1 blockers. Review fixes applied on top (fix(review) / test(review) commits: dead-code cleanup, guard dedup + added jest coverage, native crash-guard, registry cleanup, iOS blank-name + connection-ordering parity).

Before release: E2E scenario in E2ETestRunner + old/new-arch matrix run, native unit tests (JUnit/XCTest), and a native build to verify the native review fixes (they compile-clean by inspection but weren't built in review). Design follow-ups noted in the diff.

🤖 Generated with Claude Code

kherembourg and others added 30 commits July 10, 2026 15:21
…ome, interceptor)

Introduces the TypeScript surface for the v6 bridge contract:

- `PresentationBuilder.placement(id) | screen(id) | default()` chain with
  `onLoaded`, `onPresented`, `onCloseRequested`, `onDismissed`.
- `PresentationRequest.preload()` and `display()` (resolves at dismiss).
- `PresentationOutcome` (5 fields: presentation, purchaseResult, plan,
  closeReason, error) with exclusion rule error ⇒ closeReason == null.
- `Transition`, `InterceptorInfo`, `InterceptResult`, `PresentationActionKind`,
  typed `ActionPayload` union.
- `PurchaselyBuilder` start chain (`apiKey().runningMode().allowDeeplink()…`)
  exposed via `Purchasely.builder(apiKey)`.
- `Purchasely.interceptAction`, `removeActionInterceptor`,
  `removeAllActionInterceptors`.

Legacy v5 APIs (`fetchPresentation`, `setPaywallActionInterceptor`,
`readyToOpenDeeplink`, `setPaywallActionInterceptorCallback`, `start({...})`)
are kept and annotated `@deprecated`.

Bumps the SDK version to 6.0.0 and updates the related test expectations.
All 139 existing tests still pass.

Ref: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…interceptor)

Adds a new `PurchaselyV6Bridge` helper that maps the v6 cross-platform contract
to the underlying Android v6 SDK:

- `v6Preload(requestId, payload)` / `v6Display(requestId, payload, transition)`
  build a `PLYPresentationBase.Prepared` from the JS payload, attach the
  `onPresented` / `onCloseRequested` / `onDismissed` callbacks and emit them
  through the existing `RCTDeviceEventEmitter` as
  `PURCHASELY_V6_{LOADED,PRESENTED,CLOSE_REQUESTED,DISMISSED}`.
- `v6Close(requestId)` / `v6Back(requestId)` provide programmatic control over
  the live presentation.
- `v6RegisterInterceptor(kind)` uses the new typed
  `Purchasely.interceptAction(actionType, callback)` (Java/`Class<>` overload)
  to expose every concrete `PLYPresentationAction` subclass and forwards the
  typed payload to JS through `PURCHASELY_V6_ACTION_INTERCEPTED`.
- `v6CompleteInterceptor(callbackId, result)` resolves the suspended
  `CompletableDeferred` with the JS-supplied `PLYInterceptResult`.
- `v6UnregisterInterceptor(kind)` calls `Purchasely.removeActionInterceptor`.
- `v6ApplyStartOptions({allowDeeplink, allowCampaigns})` chains the v6 start
  options onto the existing `start(...)` native method.

The legacy v5 bridge methods (`fetchPresentation`, `presentPresentation*`,
`setPaywallActionInterceptor`, `onProcessAction`) — whose underlying SDK APIs
are removed in v6 — now reject with a `v6_migration_required` message that
points consumers at the v6 builder. Internal `sendPurchaseResult` is rewritten
on top of `PLYPresentationOutcome` (`sendPurchaseResultV6`). `PLYProductActivity`
is reduced to a stub kept only for the AndroidManifest, and
`PurchaselyViewManager` is rewritten to preload + buildView with v6 APIs.

Bumps the native SDK dependencies (`core`, `google-play`, `huawei-services`,
`amazon`, `player`) to 6.0.0.

Known follow-ups:
- The Android SDK 6.0.0 must be published to Maven before the example app
  can build natively.
- `v6Back` is currently a no-op log; the SDK does not expose a per-request
  back API yet.

Ref: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WIP scaffolding for the iOS v6 bridge (PurchaselyRNV6.h declares the
category on top of PurchaselyRN). Implementation comes next.

Also ignores local caches that polluted git status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the v6 cross-platform bridge contract on iOS using the existing
Purchasely 5.7.4 APIs while the native v6 SDK lands. Adds:

- v6Preload / v6Display / v6Close / v6Back exported methods
- v6RegisterInterceptor / v6UnregisterInterceptor / v6CompleteInterceptor
  using the single global setPaywallActionsInterceptor + a kind dispatcher
- v6ApplyStartOptions for allowDeeplink/allowCampaigns chain

Synthesizes the 5-field outcome (presentation, purchaseResult, plan,
closeReason, error) and onPresented(error?) callbacks per the contract
workarounds P0.2 / P0.4 / P1.1 — closeReason stays null on iOS until the
native pipeline exposes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes the 5 v6 lifecycle event names (PURCHASELY_V6_LOADED,
PRESENTED, CLOSE_REQUESTED, DISMISSED, ACTION_INTERCEPTED) through the
RCTEventEmitter supportedEvents array and pulls in the new V6 category
header so the methods are linked into the main module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a v6 builder showcase to the example: PurchaselyBuilder.apiKey()
chained start, PresentationBuilder.placement() with onLoaded /
onPresented / onCloseRequested / onDismissed callbacks, and a typed
'purchase' interceptor.

The legacy v5 setupPurchasely() flow stays the default — the new
setupPurchaselyV6() entry is wired but commented out in useEffect so
users opt in explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README: add a "Migration to v6.x" section with before/after snippets
  for init, paywall display, action interceptor and the 5-field outcome
- CHANGELOG (new file): document the v6.0.0-beta.0 release contents,
  the dual API strategy, the iOS workarounds and the deprecated v5 entry
  points
- package.json: bump version to 6.0.0-beta.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 tests validating:
- PresentationBuilder.placement/screen → v6Preload payload format
- screenId → presentationId mapping (P1.1)
- display() resolves at DISMISS not at trigger (P0.3)
- onPresented synthesizes (null, error) on render fail (P0.4)
- Outcome carries 5 fields with closeReason / error mutually exclusive (P0.2)
- Action interceptor registry + cross-kind isolation
- Orphan events not auto-resolved (native handles timeout)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- gitignore: split the corrupted `.nx/workspace-datajest_dx/` line back into
  `.nx/workspace-data` + `jest_dx/` (merge dropped the trailing newline).
- android: stop double-firing onDismissed on display errors — reject only and
  let the JS .catch synthesize the dismissed outcome (matches iOS error path).
- ios: PresentationBuilder.default() now reads the `isDefault` flag and fetches
  the default presentation via fetchPresentationWith:nil (fixes 400 in preload
  + display).
- ios: serialise all access to the shared kV6* mutable collections behind
  @synchronized(kV6StateLock) to avoid RN-thread/main-queue data races.
- v6 close(): document + warn that the native SDK has no per-request close yet,
  so closeAllScreens() dismisses every displayed presentation.
Address the two open Greptile findings on the second review pass of
PurchaselyV6Module.kt:

- Interceptor timeout (P1, real): wrap `deferred.await()` in
  `withTimeoutOrNull(INTERCEPTOR_TIMEOUT_MS = 30s)` so the coroutine never
  suspends indefinitely when JS never calls `completeInterceptor` (e.g.
  after a bridge reload). On timeout we default to NOT_HANDLED and drop the
  `pendingInterceptors` entry, so neither the SDK action nor the `complete`
  lambda is held alive. This fulfils the "native must time out" contract
  already documented in v6.integration.test.ts.

- isDefault on Android (no behaviour change): an empty builder already
  resolves the default presentation — PLYPresentationManager routes a request
  with null placementId+presentationId to apiService.getPresentation(null),
  which substitutes "ply_default". This is the exact mirror of iOS
  fetchPresentationWith:nil; documented the intentional implicit handling in
  buildPrepared so it isn't re-flagged.

- Tests: lock `default()` -> `isDefault:true` with null placement/presentation
  ids (guards the iOS isDefault branch added in fbc99b6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BREAKING CHANGE: the legacy v5 paywall API is removed (not deprecated).
There is no soft-transition / dual mode anymore. Paywalls are displayed and
intercepted exclusively through the v6 builders. Version-agnostic core
methods (user, products, subscriptions, attributes, listeners,
presentSubscriptions, clientPresentation*) and the embedded PLYPresentationView
are UNCHANGED.

Removed (TS + iOS + Android):
- start({...})            → Purchasely.builder(apiKey)...start()
- fetchPresentation       → presentation.placement(id).build().preload()
- presentPresentation(*)  → presentation.placement|screen(id).build().display()
- presentProductWithIdentifier / presentPlanWithIdentifier
                          → presentation.screen(id).contentId(c).build().display()
- show/hide/closePresentation → request.display() / request.close()
- setPaywallActionInterceptor(Callback) / onProcessAction
                          → interceptAction(kind, handler)
- setDefaultPresentationResultCallback/Handler (TS + iOS)
                          → request.onDismissed(outcome => …)
- readyToOpenDeeplink (JS wrapper) → builder(apiKey).allowDeeplink(true).start()

Kept native primitives the v6 layer depends on: native start &
readyToOpenDeeplink (called by the v6 start builder on both platforms);
Android setDefaultPresentationResultHandler (the embedded view manager's
defaultPurchasePromise fallback). iOS removed its variant since the iOS view
uses purchaseResolve directly.

Details:
- TS (src/index.ts): dropped the 16 v5 paywall declarations + now-unused
  imports; v6 façade (builder/presentation/interceptAction) is the only
  paywall API. Pruned 19 obsolete tests in index.test.ts.
- iOS: removed 12 v5 paywall RCT methods + their exclusive private helpers
  and 4 header properties from PurchaselyRN.m/.h; v6 category & view intact;
  supportedEvents keeps the merged core+v6 event list.
- Android: removed the v5 paywall @ReactMethods + the orphaned ProductActivity
  inner class; deleted PLYProductActivity.kt, its manifest entry and proguard
  keep rule; transformPlanToMap & the v6 bridge intact.
- example/: rewritten to the v6 builder/presentation/interceptAction API.
- docs: added MIGRATION-v6.md (old→new mapping) and updated README,
  sdk_public_doc.md, CLAUDE.md and CHANGELOG.

Verified: yarn test (133 ✓), yarn typecheck ✓, yarn lint ✓. Native code is
not compilable in this environment (native 6.0.0 SDKs unpublished) and was
verified structurally (grep/brace-balance) + adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ingle module

Removed the standalone `PurchaselyV6Bridge` object (PurchaselyV6Module.kt) and
inlined its logic directly into `PurchaselyModule` so the Android side exposes a
single native module.

- The 8 v6 @ReactMethod entry points (v6Preload/v6Display/v6Close/v6Back/
  v6RegisterInterceptor/v6UnregisterInterceptor/v6CompleteInterceptor/
  v6ApplyStartOptions) now hold the implementation directly instead of
  delegating to PurchaselyV6Bridge — the JS contract is unchanged.
- v6 helpers (buildV6Prepared, wireV6Callbacks, toV6Map/toV6Payload/toV6String/
  toV6Ordinal) are now private members; they reuse the module's existing
  `sendEvent` and companion `transformPlanToMap` (no duplication).
- Event-name constants, the interceptor timeout, and per-request state
  (activeV6Requests, pendingV6Interceptors) live in the companion object to
  preserve the process-global semantics the former object singleton had.
- Deleted packages/.../reactnativepurchasely/v6/PurchaselyV6Module.kt and the
  now-empty v6/ package directory. No remaining references to PurchaselyV6Bridge.

Verified: brace balance 276/276, no orphaned references, imports complete;
yarn test (133 ✓) / typecheck ✓ / lint ✓. Kotlin not compilable in this
environment (native 6.0.0 SDK unpublished) — verified structurally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the Android single-module merge on iOS and remove the "v6" branding
from the whole codebase now that v6 is the only API.

iOS — single bridge:
- Merged the PurchaselyRN (V6) category into the main @implementation in
  PurchaselyRN.m (statics, C helpers, private methods and the 8 RCT bridge
  methods). Deleted PurchaselyRNV6.h / PurchaselyRNV6.m. One @implementation,
  braces 298/298.

TypeScript — no more v6 folder:
- Moved src/v6/{events,interceptor,presentation,startBuilder}.ts to src/ and
  src/v6/types.ts to src/presentationTypes.ts (avoids the src/types.ts clash).
  Folded the src/v6/index.ts barrel into src/index.ts. Renamed
  __tests__/v6.integration.test.ts -> presentation.integration.test.ts.

No "v6" mention left in code — synchronized rename across TS + iOS + Android +
example + tests (verified: zero [Vv]6 tokens in *.ts/tsx/kt/java/m/h/swift):
- Bridge methods: v6Preload->preloadPresentation, v6Display->displayPresentation,
  v6Close->closePresentation, v6Back->goBackToPreviousScreen,
  v6RegisterInterceptor->registerActionInterceptor,
  v6UnregisterInterceptor->unregisterActionInterceptor,
  v6CompleteInterceptor->completeActionInterceptor,
  v6ApplyStartOptions->applyStartOptions.
- Events: PURCHASELY_V6_LOADED/PRESENTED/CLOSE_REQUESTED/DISMISSED ->
  PURCHASELY_PRESENTATION_*, PURCHASELY_V6_ACTION_INTERCEPTED ->
  PURCHASELY_ACTION_INTERCEPTED, PURCHASELY_V6_EVENTS ->
  PURCHASELY_PRESENTATION_EVENTS, purchaselyV6EventEmitter ->
  presentationEventEmitter.
- All internal v6/V6-prefixed identifiers (iOS kV6*/V6*, Android *V6*, TS
  V6LifecycleEvent/V6InterceptorEvent) renamed; v6 log tags -> [Purchasely];
  NSError domain io.purchasely.v6 -> io.purchasely.presentation.

Wire names verified present & matching across TS/iOS/Android. The public API
(PresentationBuilder, PurchaselyBuilder, interceptAction, PLYPresentationView)
is unchanged. yarn test (133 ✓) / typecheck ✓ / lint ✓. Native verified
structurally (brace balance, single @implementation, wire-name sync) — the
6.0.0 SDK is unpublished so it cannot be compiled here.

Docs still reference "v6"/6.0.0 as the version/migration concept (out of scope
of the code-only rename).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ersion

- synchronize(): bridge the native success/error callbacks to a JS Promise
  (Android onSuccess/onError; iOS was already wired). Source-compatible — the
  promise resolves on completion and rejects on failure.
- Pin io.purchasely:* (core/google-play/player/amazon/huawei) and the Purchasely
  pod to 6.0.0-rc.1 (published on Maven Central / CocoaPods trunk).
- Fix the Android module test: import PLYPresentationType from its v6 package
  (io.purchasely.ext.presentation), unblocking the native unit-test suite.
- Bump all 5 packages + the bridge version to 6.0.0-rc.1.
- Docs: MIGRATION-v6 (synchronize awaitable), VERSIONS, sdk_public_doc,
  V6_MIGRATION_REPORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the repo from **React Native 0.79.2 → 0.86.0** (example app,
the 5 packages' devDeps, and `rn-purchasely-test`). Based on
`feat/sdk-v6-migration`. Supersedes #213 (RN 0.83).

- `react` 19.0.0 → **19.2.3**, `react-native` 0.79.2 → **0.86.0**
-
`@react-native/{babel-preset,eslint-config,metro-config,typescript-config,jest-preset}`
→ **0.86.0**
- `@react-native-community/cli` (+ platforms) 15.0.1 → **20.1.0**
- `@types/react` → **^19.2.0**, `react-test-renderer` → **19.2.3**,
`typescript` → **^5.8.3**, `metro-*` → **^0.84**

- Android: Gradle **8.12 → 9.3.1**, buildTools/compileSdk/targetSdk **35
→ 36** (minSdk 24, NDK 27.1.12297006, Kotlin 2.1.21 unchanged; New
Architecture already enabled).
- Node: `.nvmrc` **v20 → v22**, `engines.node >=22.11` (RN 0.86
requirement; CI `setup` reads `.nvmrc`).
- Root `resolutions`: `@types/react` → ^19.2.0, dropped obsolete
`@types/react-native`.

- Jest preset moved out of `react-native` → added
`@react-native/jest-preset`; `babel.config.js` →
`@react-native/babel-preset` (Hermes-Flow parser).
- `postinstall` restores the executable bit on
`@react-native-community/cli`'s `build/bin.js` (upstream 20.x packaging
bug that breaks Gradle autolinking under Yarn 3).
- `react-native-screens` → ^4.16 (`ShadowNode::Shared` removal in RN
0.86).
- Added `docs/react-native-upgrade-best-practices.md`.

- ✅ `yarn install`, `yarn typecheck`, `yarn lint`
- ✅ `yarn test` — 5 suites / 137 tests
- ✅ `yarn prepare` (builder-bob lib build)
- ✅ example Android `:app:assembleDebug` — BUILD SUCCESSFUL (Gradle
9.3.1, SDK 36, APK generated)

- iOS: `pod install` on a compatible Xcode (not run here; pbxproj
deployment target unchanged — 15.1 is the RN 0.86 minimum).
- `expo-purchasely-test` left on Expo SDK 54 (managed RN) — bump via
Expo SDK, not a raw RN pin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bscriptions

The iOS bridge still called native v5 APIs that were removed from Purchasely
6.0.0-rc.1, so the example app failed to compile (errors were masked behind the
earlier fmt failure). Migrate the bridge to the v6 builder API:

- PurchaselyRN.m / PurchaselyView.swift: PLYPresentationBuilder.forPlacementId/
  forScreenId -> build() -> preloadWithCompletion: + onDismissed:
  (PLYPresentationOutcome); keep showController:type:from: for display;
  closeDisplayedPresentation -> [presentation close] / closeAllScreens.
- RunningMode: TransactionOnly/PaywallObserver removed natively (only Observer=2/
  Full=3 remain) -> local PLYRNRunningMode ordinals + runningModeFromOrdinal()
  mapping, matching Android.
- setDynamicOffering: pass the new billingPlanType: argument (Unspecified).
- PLYPresentationPlan+Hybrid.m: self.default (ObjC keyword) -> self.default_.

BREAKING CHANGE: presentSubscriptions() is removed on iOS and Android (and from
the JS surface). The native v6 SDKs no longer ship a built-in subscription-list
UI; build your own screen from userSubscriptions().

Verified: yarn typecheck + lint clean, Jest 136/136, and
`react-native run-ios --device "iPhone KH"` builds, installs and launches on a
physical device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: list presentSubscriptions() in the removed-methods section.
- MIGRATION-v6.md: presentSubscriptions() is removed on iOS and Android, not a
  source-compatible no-op.
- V6_MIGRATION_REPORT.md: add an addendum superseding doubts #2/#7 — the iOS
  bridge did not actually compile against rc.1; it is now migrated to the v6
  builder API and the example app builds/installs/launches on a physical device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the iOS native fallback with the v6 default (observer) and with Flutter's
`PLYRunningMode(rawValue:) ?? .observer`. Previously an unknown/unset ordinal
resolved to Full (mirrored from Android); now only `full` opts into Purchasely
owning the purchase flow. `full`/`transactionOnly` still map to Full,
`observer`/`paywallObserver` to Observer.

Document the default-mode switch in MIGRATION-v6.md as a major, *silent*
behavioural breaking change (no compile error): apps that relied on v5's
implicit full mode must now pass `.runningMode('full')`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the presentSubscriptions removal: the example Home screen still
wired a "Display Subscriptions" button to Purchasely.presentSubscriptions(),
which no longer exists and would throw at runtime when tapped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the React Native bridge with the Flutter SDK on the local
`6.0.0-beta.12` native build (resolved via mavenLocal), which ships the
renamed global handler `Purchasely.setDefaultPresentationDismissHandler`.
The previously pinned `6.0.0-rc.1` predates that rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v6 renames the native global handler for presentations the app does not
instantiate itself (campaigns, deeplinks, Promoted In-App Purchases) from
`setDefaultPresentationResultHandler` (block: result, plan) to
`setDefaultPresentationDismissHandler` (single rich `PLYPresentationOutcome`).
Expose the same name and a rich outcome cross-platform, mirroring Flutter.

- TS: `Purchasely.setDefaultPresentationDismissHandler(outcome => …)` returns
  an `EmitterSubscription`; `removeDefaultPresentationDismissHandler()` clears
  it. A single global handler is kept (re-registering replaces it), matching
  the native contract. Outcomes flow over a dedicated
  `PURCHASELY_DEFAULT_PRESENTATION_DISMISSED` event (no requestId) reusing the
  existing outcome mapping. Add `interactiveDismiss` to `CloseReason`.
- Android: rename the bridge method, call the renamed native handler, emit the
  rich outcome, and drop the now-dead legacy `{result, plan}` projection
  (`sendPurchaseResult` + the shared promises).
- iOS: add the bridge method, emit the rich outcome (with `closeReason`). The
  native selector is forward-declared and guarded with `respondsToSelector:`
  so the bridge keeps compiling against SDK builds that predate the rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TS integration: register → emit DEFAULT_DISMISSED → assert the rich outcome
  (purchaseResult, closeReason incl. interactiveDismiss, plan, presentation);
  single-handler replacement; removeDefaultPresentationDismissHandler stops
  deliveries. Update the two native-module mocks to the renamed method.
- iOS: assert the event is in supportedEvents and the bridge method is exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Distinguish the two paywall flows in the migration guide: paywalls the app
displays (read the result from the request) vs. paywalls the SDK opens itself
(campaigns/deeplinks/Promoted IAP) handled by the new global
`setDefaultPresentationDismissHandler`. Update the v5→v6 mapping table and note
the cross-platform `closeReason` superset (backSystem / interactiveDismiss).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Reason/transition)

Mirror the native iOS/Android v6 SDK contract on the React Native surface:

- Rename PresentationOutcome -> PLYPresentationOutcome (matches native
  PLYPresentationOutcome on both platforms + the PLY public-type convention).
- Deeplink: rename isDeeplinkHandled -> handleDeeplink end to end (JS, iOS
  RCT_EXPORT_METHOD, Android @ReactMethod), matching native handleDeeplink.
- closeReason: drop the parasite `interactiveDismiss` value; the union is now
  { button, backSystem, programmatic } | null, mirroring PLYCloseReason. iOS
  interactive dismiss maps to backSystem for parity with Android system back.
  Also wire closeReason through the iOS dismiss outcome (it was dropped before).
- Transition (breaking): remove the legacy heightPercentage; add width/height as
  PLYTransitionDimension { type: 'pixel' | 'percentage', value }, mirroring the
  native PLYTransition. Android bridge builds PLYTransitionDimension accordingly.

Tests, mocks, migration guide, public doc and example comments updated.
typecheck + lint + 140 jest tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements a 10-test E2E suite that drives the JS public API through the
native Android bridge against the real Purchasely backend (mirror of the
Flutter integration tests documented in Flutter/E2E_TEST_INDEX.md).

Tests:
  T1  getAnonymousUserId
  T2  isAnonymous: true → login → false → logout → true
  T3  preload(placement) → typed Presentation
  T4  getDynamicOfferings
  T5  allProducts
  T6  synchronize() (expected to reject on emulator without billing)
  T7  interceptAction register/removeActionInterceptor/removeAll round-trip
  T8  display(drawer 60%) → sleep → close() → outcome.closeReason
  T9  purchase interceptor fires on real UIAutomator tap
  T10 setDefaultPresentationDismissHandler via deeplink + system BACK

Key fixes:
  - MainActivity.onPause() is a no-op in E2E mode: prevents React Native
    New Architecture (Bridgeless/Hermes) from suspending the JS timer queue
    when PLYFlowActivity takes focus, which would freeze all JS awaits.
  - index.js routes to E2ETestRunner when initialProp e2eMode=true is set
    by MainActivity.getLaunchOptions() (workaround for Bridgeless singleTask
    ignoring getMainComponentName() on onNewIntent).
  - run_e2e.sh: ASCII-only, LOGCAT_PID guard, uninstall-before-install.
  - T8/T9 use sleep(3000) instead of waitFor(onPresented) because the SDK
    beta.12 does not reliably fire the onPresented callback after display().

Verified: all 10 tests pass on emulator-5554 (Android 16, API 36).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New `.github/workflows/e2e-android.yml`: runs T1-T10 on macos-latest
  (arm64) via reactivecircus/android-emulator-runner (API 34, arm64-v8a).
  Triggers: workflow_dispatch + nightly cron at 02:00 UTC.
  Uploads logcat on failure.
- Add `--debug` flag to `run_e2e.sh` to use the debug APK (no signing
  required in CI) and `assembleDebug` instead of assembleRelease.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace 6.0.0-beta.12 (unpublished, local-only) with 6.0.0-rc.2 across
all Android packages (core, google-play, huawei-services, amazon, player).
All five artifacts confirmed on Maven Central.

Also updates the E2E CI push trigger to fire on build.gradle and
integration_test changes (not just the workflow file itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
macos-latest runners (Apple Silicon) return HV_UNSUPPORTED when the
Android emulator tries to use Hypervisor.framework — the runner VMs
lack the hypervisor entitlement. The emulator falls back to software
mode and never boots within the 10 min timeout.

ubuntu-latest supports KVM hardware acceleration: emulator boots in
~2 min, x86_64 system image (API 34) is well supported.
iOS E2E will use a separate macos-latest job when added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kherembourg and others added 21 commits July 10, 2026 15:22
….2 surface

- versions: 6.0.0-rc.2 everywhere (npm, native iOS/Android pins), RN 0.86,
  TS 5.8.3, Node v22, minSdk 23; VERSIONS.md gains the rc.2 row and the
  5.7.3 line is back in chronological order
- MIGRATION-v6.md: real exported type names (PLYPresentationBuilder,
  PLYPresentationRequest), defaultSource() entry point, presentation
  builder options (backgroundColor/progressColor/displayCloseButton/
  displayBackButton with per-platform semantics), full transition types
  and dimensions, cold-start handleDeeplink builder modifier, and the
  PLYLoadedPresentation lifecycle (display/close/back)
- drop the last unprefixed/legacy enum references (PLYPaywallAction,
  4-value RunningMode) from CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…presentation builder

The JS presentation builder has been sending these keys since v6, and the
Android bridge already consumes them (PurchaselyModule.kt); the iOS bridge
silently dropped them. Tri-state guard mirrors Android: absent key or JSON
null leaves the backend-defined visibility untouched. Note iOS semantics
are suppression-only (only false hides a backend-shown button).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- PLYPresentationBuilder.defaultSource() is the canonical cross-platform
  entry point (default() stays as the iOS-matching alias)
- preload() now resolves a PLYLoadedPresentation exposing
  display(transition?)/close()/back() delegating to its origin request,
  mirroring the Flutter loaded-presentation lifecycle
- drop the legacy v5 PLYPaywallAction enum (snake_case, open_flow_step);
  PLYPresentationActionKind is the only action vocabulary
- reduce RunningMode to OBSERVER/FULL — the v6 natives removed
  TransactionOnly and PaywallObserver
- expose a public read-only requestId getter on PLYPresentationRequest
- document the real displayCloseButton/displayBackButton platform
  semantics (Android toggle, iOS suppression-only)

BREAKING CHANGE: PLYPaywallAction is removed and RunningMode no longer
declares TRANSACTION_ONLY/PAYWALL_OBSERVER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t={...}>

Port of PR #254 onto the aligned v6 API. The component accepts a
PLYPresentationRequest preloaded upstream and reuses its native
presentation by requestId on both platforms (iOS
loadedPresentationForRequestId, Android PurchaselyModule.loadedPresentation),
falling back to placementId/presentation. onPresentationClosed is now
typed with the exported PLYPresentationViewResult ({ result, plan }, which
is what the native embedded view actually emits). Example gains a
PaywallPreloaded demo screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…handler

Align the dismiss routing rule with Flutter/native (local onDismissed ??
global default handler): a presentation displayed by the app whose request
has no local onDismissed now delivers its outcome to the handler registered
via setDefaultPresentationDismissHandler, in addition to resolving the
display() promise. A local onDismissed still wins and suppresses the
default routing for that presentation.

The native DEFAULT_DISMISSED event only fires for SDK-opened presentations
(campaign/deeplink/Promoted IAP) which carry no requestId, so each
dismissal reaches the default handler through exactly one path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the seven scenarios present in the Flutter E2E suite but missing here:
- T21 synchronize() settles (resolve or clean store rejection, no hang)
- T22 default dismiss handler catches a fire-and-forget display()
- T23 local onDismissed wins over the default handler
- T24 user attribute listener set/removed events
- T25 embedded <PLYPresentationView request> renders (close best-effort,
  per Flutter INLINE_PAYWALL_CLOSE.md findings)
- T26 PLYLoadedPresentation display/close lifecycle
- T27 cold-start deeplink via builder .handleDeeplink() before start():
  Android runs it as a dedicated fresh-process phase (E2E_PHASE intent
  extra relaunch in run_e2e.sh); iOS emits an explicit [E2E:T27:SKIP]
  (no launch-arg phase bridge in the example AppDelegate yet)

Adds a skip status and [E2E:Tn:SKIP] marker, extends both host scripts'
report loops to T21-T27, and documents everything (steps, asserts,
divergences vs Flutter, cross-wrapper mapping) in E2E_TEST_INDEX.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path filters only watched integration_test/, the example runner and
build.gradle/podspec files, so a pure bridge change (packages/purchasely
JS, iOS or Android source) never triggered the E2E workflows. Add
packages/purchasely/src/**, packages/purchasely/ios/** and
packages/purchasely/android/src/** to push and pull_request filters of
both workflows, matching how Flutter gates its E2E on purchasely/lib/**.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounting <PLYPresentationView request={...}> hard-froze the whole app
(CI E2E T25): Fabric instantiated the PurchaselyView manager on the main
thread while the JS effect concurrently resolved the same module via
NativeModules.PurchaselyView; with requiresMainQueueSetup=true the JS
thread dispatch_sync'd onto the blocked main queue — a module-holder
deadlock proven by process samples (main thread in
condition_variable::wait, JS thread in RCTUnsafeExecuteOnMainQueueSync).
requiresMainQueueSetup now returns false; view() is still main-queue.

Also rework the controller mounting to the pattern proven in the Flutter
NativeView: frame-based layout resynced in layoutSubviews (no Auto Layout
against the Yoga host), addChild/didMove containment on the root VC, and
a BALANCED appearance transition driven from didMoveToWindow (the old
begin-without-end left viewDidAppear unfired), with symmetric teardown.

The iOS SDK only emits PRESENTATION_VIEWED through its own full-screen
display flow, so a manually embedded controller rendered without ever
surfacing the event (Android's SDK does fire it for embedded views). The
view now emits it to JS listeners when the paywall actually appears —
cross-platform parity, no backend analytics involved, and no double-fire
possible since the full-screen path never goes through PurchaselyView.

Verified locally: full iOS E2E suite green (T1-T26 PASS, T27 SKIP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Podfile.lock finally records the local pod at 6.0.0-rc.2 (podspec was
bumped earlier without regenerating the lock); the pbxproj/Info.plist
changes (RCT_REMOVE_LEGACY_ARCH, PODFILE_DIR, RCTNewArchEnabled) are
injected automatically by the React Native CocoaPods scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…med, not removed

The v6 migration wrongly removed the client (BYOS) presentation API. The
native SDKs keep it: Android is unchanged, iOS only renamed the native call
(clientPresentationOpened → clientPresentationDisplayed).

Restore both methods at every layer under the same v5 public names:

- TS: clientPresentationDisplayed/Closed(presentation) — sends only the
  identifiers (screenId, placementId) since a PLYLoadedPresentation also
  carries functions the RN bridge cannot serialize.
- iOS: v6 PLYPresentation is a protocol that cannot be rebuilt from a JS
  dictionary, so the bridge resolves the loaded native instance from the
  per-request registry (kPresentationsByRequest) by screenId (fallback
  placementId). Calls the new clientPresentationDisplayedWith: selector,
  guarded with respondsToSelector: + fallback to the rc.2
  clientPresentationOpenedWith: (same pattern as the default dismiss handler).
- Android: same resolution against activeLoadedPresentations, then the
  unchanged Purchasely.clientPresentationDisplayed/Closed.
- Unresolvable presentation → clear warning + no-op on both platforms.
- Docs: MIGRATION-v6.md and CHANGELOG.md now document the API as kept.
- Tests: index.test.ts asserts the API exists and forwards the identifiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allbacks

rc.3 ships the renamed iOS entry points, so the bridge now calls
clientPresentationDisplayedWith: and setDefaultPresentationDismissHandler:
directly — the respondsToSelector: guards, the rc.2
clientPresentationOpenedWith: fallback and both forward-declaration
categories are removed.

Pins bumped to 6.0.0-rc.3: RN podspec (pod Purchasely), Android gradle
(io.purchasely core / google-play / amazon / huawei-services / player),
example Podfile.lock version strings (checksums will be rewritten by
pod install once rc.3 is published), CLAUDE.md native-deps notes.

Wrapper versions (package.json, bridgeVersion, VERSIONS.md history) are
untouched — they track the RN package release, not the native pin.

Note: native builds will not compile/resolve until the native 6.0.0-rc.3
artifacts are published. JS tests, typecheck and lint all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native never nests `requestId` inside the presentation payload
(`toRNMap`/`presentationToMap` don't emit it); `requestId` is stamped on
by `buildLoadedPresentation` for preloaded presentations. The
`raw.requestId ?? null` normalization branch was therefore always null.
Full jest suite (151) + typecheck green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uard

- Add customScreen.test.ts covering the presentationKey fallback
  (customScreenId -> requestId), precedence, and the warn/no-op branch when
  a presentation carries neither key (#3 testing gap, #10 in review).
- Factor the resolve-key + null-check + warn shape shared by
  executeConnection/customScreenBack/customScreenClose into one
  withPresentationKey helper (#10 duplication).

jest 93 pass, typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-platform review fixes on the native bridges. NOT compiled or run in the
review environment — needs a native build + the old/new-arch matrix before merge.

Android (PurchaselyCustomScreenFragment.kt, PurchaselyModule.kt):
- Guard the onResume/onPause `application as ReactApplication` cast in a
  runCatching+warn-log (onCreateView already guards the identical cast), so an
  unexpected host shape degrades instead of crashing the host app (#1).
- Clear the customScreenPresentations registry in removeCustomScreenProvider()
  so entries never mounted / missed by the teardown guard don't leak across
  register-unregister cycles. The config-change-aware onDestroy guard is left
  intact deliberately: unconditional onDestroyView removal would break rotation
  survival (the recreated fragment re-mounts with the same customScreenId) (#6).

iOS (PurchaselyRN.m):
- Reject whitespace-only component names (trim before the length check) to match
  Android's isBlank() (#9).
- Sort marshalled connections by id: iOS delivers an unordered Set, so index-based
  rendering was non-deterministic and diverged from Android's ordered List (#11).

Not applied (need design input / runtime verification): mount-failure fallback UI
(#2), host-lifecycle scoping (#5), root-view reflection -> typed API (#8), native
unit tests (#3), iOS isDefault (blocked on native SDK, #7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chouaibMo chouaibMo changed the title docs(plans): Custom Screens (BYOS) implementation plan feat: Custom Screens (BYOS) support for React Native Jul 23, 2026
@chouaibMo
chouaibMo marked this pull request as ready for review July 23, 2026 10:22
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds app-owned React Native screens inside Purchasely flows. The main changes are:

  • Public provider, navigation, hook, and presentation APIs.
  • Android Fragment hosting for legacy and bridgeless React Native.
  • iOS view-controller hosting on the app's existing React runtime.
  • Connection marshalling, examples, tests, and integration docs.

Confidence Score: 4/5

The Android compatibility and provider-removal paths need fixes before merging.

  • Older React Native consumers allowed by the package can fail to compile the new Fragment.
  • Provider removal can race a native Custom Screen request and leave the old provider active for that request.
  • The TypeScript and iOS API paths otherwise follow a consistent presentation-key contract.

PurchaselyCustomScreenFragment.kt and PurchaselyModule.kt

Important Files Changed

Filename Overview
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt Adds legacy and bridgeless React hosting, but directly references APIs outside the package's unrestricted React Native compatibility range.
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt Adds provider registration, presentation lookup, navigation, and connection marshalling; asynchronous provider removal can race native requests.
packages/purchasely/ios/PurchaselyRN.m Adds the iOS provider delegate, root-view host, presentation registry, navigation methods, and connection marshalling.
packages/purchasely/src/customScreen.ts Adds provider registration, presentation-key navigation helpers, and the Custom Screen hook.
packages/purchasely/src/presentation.ts Normalizes connections and Custom Screen identifiers and attaches request IDs to preloaded presentations.
packages/purchasely/src/presentationTypes.ts Defines the public Custom Screen provider, props, connection, and presentation-key types.

Sequence Diagram

sequenceDiagram
    participant App as React Native app
    participant JS as Purchasely JS API
    participant Bridge as Native bridge
    participant SDK as Purchasely SDK
    participant Host as Native screen host

    App->>JS: Register component name
    JS->>Bridge: setCustomScreenProvider(name)
    Bridge->>SDK: Install provider
    SDK->>Bridge: Request custom presentation
    Bridge->>Bridge: Store by customScreenId
    Bridge->>Host: Create Fragment or UIViewController
    Host->>App: Render component with presentation props
    App->>JS: Execute connection, back, or close
    JS->>Bridge: Send presentation key and action
    Bridge->>SDK: Resolve presentation and navigate
    Host->>Bridge: Release registry entry on teardown
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt:13
**Older React Native Builds Break**

Every Android consumer compiles this file, but `ReactNativeFeatureFlags` and the `ReactHost`/`ReactSurface` calls require newer React Native APIs while the package still allows `react-native: "*"`. Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.

### Issue 2 of 2
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt:895-899
**Provider Removal Races New Requests**

This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.

Reviews (1): Last reviewed commit: "fix(review): harden native custom-screen..." | Re-trigger Greptile

import com.facebook.react.ReactApplication
import com.facebook.react.ReactRootView
import com.facebook.react.interfaces.fabric.ReactSurface
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags

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.

P1 Older React Native Builds Break

Every Android consumer compiles this file, but ReactNativeFeatureFlags and the ReactHost/ReactSurface calls require newer React Native APIs while the package still allows react-native: "*". Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt
Line: 13

Comment:
**Older React Native Builds Break**

Every Android consumer compiles this file, but `ReactNativeFeatureFlags` and the `ReactHost`/`ReactSurface` calls require newer React Native APIs while the package still allows `react-native: "*"`. Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/purchasely/github/purchasely/purchasely-reactnative/-/custom-context?memory=774b1053-2ac2-46c7-8406-b2182cd63ef6))

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +895 to +899
// Drop any presentations captured under the outgoing provider so entries
// never mounted (or missed by the Fragment teardown guard) don't accumulate
// across register/unregister cycles.
customScreenPresentations.clear()
UiThreadUtil.runOnUiThread { Purchasely.setCustomScreenProvider(null) }

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.

P1 Provider Removal Races New Requests

This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt
Line: 895-899

Comment:
**Provider Removal Races New Requests**

This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/purchasely/github/purchasely/purchasely-reactnative/-/custom-context?memory=774b1053-2ac2-46c7-8406-b2182cd63ef6))

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

Fix in Claude Code

@kherembourg
kherembourg force-pushed the feat/sdk-v6-migration branch from f6bc9ce to 6ea05c6 Compare July 23, 2026 11:08
Base automatically changed from feat/sdk-v6-migration to main July 24, 2026 12:40
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.

4 participants