feat(v6)!: cross-platform parity audit remediation (transitions, attributes, v5 cleanup)#259
feat(v6)!: cross-platform parity audit remediation (transitions, attributes, v5 cleanup)#259kherembourg wants to merge 24 commits into
Conversation
Port the uncommitted audit test additions from the main worktree: startBuilder.test.ts (new — direct PurchaselyBuilder coverage) and updates to index.test.ts / presentation.integration.test.ts covering v5-removal assertions, Attributes.setAttribute, PLYPresentationBuilder display-option forwarding, onLoaded/onDismissed/onPresented/ onCloseRequested hot-swap, purchaseResultFromOrdinal, and the full action-interceptor payload normalization matrix. index.test.ts includes a documentation test, "[blocked by RN-W-01] propagates the Android native stub rejection", that encodes today's buggy behavior (Android signPromotionalOffer always rejects); it will be updated in the RN-W-01 fix commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cting
[RN-W-01 / REC-04] Android's native signPromotionalOffer was a permanent
stub (promise.reject("Not supported on Android")) with no Platform.OS
guard in the JS wrapper, contradicting MIGRATION-v6.md's claim that the
method is unchanged in behaviour on both platforms. Per product decision,
Android has no StoreKit-equivalent primitive, so it now resolves as a
no-op success instead of throwing. Updated the JS wrapper's JSDoc
(iOS-only capability, no-op on Android), the audit-ported test that
documented the old rejection, and the misleading MIGRATION-v6.md line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[RN-W-02 / REC-14] The iOS bridge only read `transition.dismissible` and
always displayed via the hardcoded legacy `[Purchasely showController:
type:PLYUIControllerTypeProductPage from:nil]`, ignoring `type`, `width`,
`height` and `backgroundColors` entirely — Android already parsed all of
these into a native PLYTransition. Add plyTransitionFromMap(), mirroring
the Flutter iOS plugin's parseTransition/parseDimension against the same
6.0.0-rc.3 SDK, and switch displayPresentation to the real v6 display
path (`[request displayWithTransition:completion:]`) instead of the
legacy PLYUIControllerType screen API, aligning the semantics with
Android's PurchaselyModule.kt display path.
Ceiling: SDK 6.0.0-rc.3 only bridges a legacy 0-1 heightPercentage to
Objective-C, not the typed pixel/percentage PLYDimension Swift API, and
has no Objective-C-visible width setter at all. So `height` maps fully
for `{ type: 'percentage', value }`; a pixel height and any `width` are
accepted but dropped with a warning (documented in MIGRATION-v6.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[FLT-W-02 comment / PAR-19] Both native SDKs expose Purchasely.closeAllScreens() (iOS already used it internally as the closePresentation() fallback; Android's request.close() already dismisses everything). Expose it as an explicit, cross-platform top-level bridge method (TS + RCT_EXPORT_METHOD + @ReactMethod) so apps can close every displayed screen without going through a specific PLYPresentationRequest, and document the close()-vs-closeAllScreens() distinction in MIGRATION-v6.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…NONE [RN-W-04 / ENM-07] Android's constantsToExport already includes sourceNone (StoreType.NONE.ordinal), asserted by PurchaselyModuleTest.kt, but iOS's constantsToExport never included a sourceNone key — reading NativeModules.Purchasely.getConstants().sourceNone returned undefined on iOS despite Constants.sourceNone being a required (non-optional) TS field. Add it (PLYSubscriptionSourceNone) and wire the missing SubscriptionSource.NONE case in enums.ts. Update both the TS and iOS native constant tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[RN-W-05] Android's start() when(runningMode) fell through its `else` branch to PLYRunningMode.Full for any unmatched ordinal, contradicting iOS's runningModeFromOrdinal — whose comment explicitly documents Observer as "the v6 default" for unknown/unset values. Add the missing explicit runningModeFull branch (previously covered only by the else fallback) and flip the true default to Observer, aligning both platforms. Unreachable from the public TS builder today (it only ever sends the two matched ordinals), so this is a defensive-completeness fix with no test-observable behavior change from JS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ionPlan
[RN-W-06] The native models behind PLYPresentationPlan differ: iOS has
{planVendorId, storeProductId, offerId, offerVendorId, default} with no
storeOfferId/basePlanId; Android has {planVendorId, storeProductId,
basePlanId, storeOfferId, offerVendorId} with no distinct offerId (the
bridge duplicates storeOfferId under that key). A naive integrator
reading plan.storeOfferId cross-platform would silently get nothing on
iOS. Document this with JSDoc — no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[RN-W-07] PLYPlan+Hybrid.m asDictionary set hasIntroductoryPrice, then for a free-trial plan (hasIntroductoryPrice && introAmount == 0) removed that key entirely instead of keeping it alongside hasFreeTrial. The TS PurchaselyPlan.hasIntroductoryPrice type is required (non-optional boolean); for a free-trial plan on iOS the field was undefined rather than a real boolean, breaking the type contract. hasFreeTrial keeps its existing behavior. No native test added: PLYPlan+Hybrid.m has no existing XCTest coverage, and exercising it would require either a live native SDK PLYPlan instance or exposing this private category to a test target — neither fits safely in this pass. Verified by code inspection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[RN-W-08 / REC-17] RCT_ENUM_CONVERTER mappings for PLYLogLevel,
PLYPurchaseResult, PLYAttribute, PLYSubscriptionSource and PLYPlanType,
but no RCT_EXPORT_METHOD in PurchaselyRN.m declares a parameter of any
of those custom types (every method uses raw NSInteger/NSString*), so
the RCTConvert category macros were never invoked by the bridge
macro-dispatch mechanism. Removed the file and its 4 references in
Purchasely.xcodeproj/project.pbxproj (PBXBuildFile, PBXFileReference,
PBXGroup, PBXSourcesBuildPhase). The podspec globs ios/Classes/**/*.{h,m}
so no podspec change is needed; verified with `plutil -lint` that the
trimmed pbxproj is still structurally valid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[RN-W-09 / REC-17] v5-signature init method (apiKey, stores, userId, logLevel, runningMode, sdkVersion — no storeKit1 param, unlike start()). Zero references in Android or in the TS src/ tree (startBuilder.ts only ever calls the native `start` method); fully orphaned. index.test.ts already asserts 'startWithAPIKey' is absent from the public JS Purchasely object, unaffected by this native-only removal. No header declaration existed to remove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bserver) [ENM-06 / REC-17] runningModeTransactionOnly / runningModePaywallObserver were exported on both native bridges and in the TS Constants interface with no corresponding case in the RunningMode enum (enums.ts already only exposes OBSERVER/FULL) — pure v5 residue. Removed from PurchaselyRN.m, PurchaselyModule.kt (constants map, the companion object consts, and the now-simplified start() runningMode `when` — Full/Observer keep their original ordinals 4/1 so already-shipped wire values don't change), interfaces.ts, and the mock/native tests that asserted them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oneSignalUserId [ENM-04 / REC-11] onesignalPlayerId was iOS-only (PLYAttributeOneSignalPlayerId) with no Android equivalent, and is slated for removal from the native iOS SDK too — removed from the iOS constantsToExport and the TS Attributes enum/Constants interface. oneSignalExternalId/oneSignalUserId are present on both natives (PLYAttributeOneSignalExternalId/UserId on iOS, Attribute.ONESIGNAL_EXTERNAL_ID/ONESIGNAL_USER_ID on Android) but were never bridged — added on both platforms, mapped by constant name (never an ordinal literal in TS). Updated the mock constants and both native test files (PurchaselyRNTests.m, PurchaselyModuleTest.kt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[PAR-12 / REC-17] getConstants() leaked a raw, 64-numeric-field native constants blob as public API, including dead v5 residue, when every typed enum in the package (LogLevels, SubscriptionSource, Attributes, etc.) already derives from the equivalent native constants internally. Removed the now-dead module-level `constants`/`getConstants` pair from index.ts (each enum module fetches its own constants independently, so nothing else in index.ts depended on it) and its export. Documented as a breaking change in MIGRATION-v6.md. BREAKING CHANGE: Purchasely.getConstants() is no longer available. Use the typed enums (LogLevels, SubscriptionSource, Attributes, PlanType, RunningMode, PLYThemeMode, PLYPresentationType, PLYUserAttributeSource, PLYUserAttributeType) instead of reading raw constant fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e barrel [PAR-14 / REC-17] purchaseResultFromOrdinal is documented @internal but was re-exported publicly through index.ts's `export * from './presentationTypes'`. Switched that one barrel line to `export type *`, which re-exports every type/interface from presentationTypes.ts but drops its one runtime value export. Internal usage (presentation.ts imports it directly from './presentationTypes') is unaffected. Added the @internal JSDoc tag and a test pinning its absence from the public barrel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ySubscription
[PAR-24] cumulatedRevenuesInUSD and the 3 subscriptionDurationIn{Days,
Weeks,Months} fields were commented out of the TS type entirely, even
though Android's native PLYSubscription.toMap() (confirmed via
javap against the pinned core-6.0.0-rc.3.aar: getCumulatedRevenuesInUSD(),
getSubscriptionDurationInDays/Weeks/Months()) already provides them, and
the RN Android bridge passes them through unmodified. Reactivated as
optional fields with JSDoc — Android-only; iOS's PLYSubscription+Hybrid.m
asDictionary never emits them, and per product decision the iOS payload
is not touched. Added tests for both the populated (Android) and
omitted (iOS) shapes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[PAR-30] Both native SDKs support choosing whether to clear locally stored user attributes on logout (iOS: Purchasely.userLogout(BOOL); Android: Purchasely.userLogout(clearUserAttributes: Boolean = true)), but no bridge exposed it — iOS hardcoded YES, Android always used the native default with no way to opt out. Added the parameter to the TS wrapper (defaulting to true, matching both natives), the iOS RCT_EXPORT_METHOD and the Android @ReactMethod. Documented in MIGRATION-v6.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[PAR-07] Only the write side (clearBuiltInAttributes) was bridged on any platform; the read side was missing everywhere despite both natives already exposing it (iOS: +getBuiltInAttributes / +getBuiltInAttributeWith:; Android: Purchasely.getBuiltInAttributes() / getBuiltInAttribute(String)). Added both methods to the TS wrapper and both native bridges, reusing the existing getUserAttributeValueForRN value-shaping helper on each platform for consistency with userAttributes()/userAttribute(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[REC-15 / ENM-12] Traced the RGPD consent path end to end: RN's TS enum
sends kebab-case singular strings ("third-party-integration"), which
both native bridges' mapPurposesFromStrings/mapPurposesFromReadableArray
already correctly translate into the real native PLYDataProcessingPurpose
enum before calling revokeDataProcessingConsent — the raw string never
reaches the backend directly, only the resulting native enum does. This
bridge's own mapping was already internally consistent and correct;
the "4 conventions" divergence noted by the audit is a cross-SDK
(RN/Flutter/Cordova/native) wire-string-convention difference, not a bug
in this bridge.
Per the "align en douceur" decision, widen both native mappers to also
accept the SCREAMING_SNAKE_CASE plural convention used by the other
Purchasely SDKs (e.g. "THIRD_PARTY_INTEGRATIONS", "ALL_NON_ESSENTIALS")
alongside the existing kebab strings, without changing behavior for
callers already using the documented kebab form. Added a Kotlin JUnit
test pinning both conventions; no iOS native test was added (exercising
revokeDataProcessingConsent: would call the live native SDK, which no
existing XCTest in this file does, and this bridge otherwise has no test
seam that doesn't require either a header-exposed private method or a
live SDK call).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[rc.4 hardening] The Android native SDK is expected to start emitting PurchaselyPlan.type as a DistributionType string (e.g. "RENEWING_SUBSCRIPTION") in a future rc.4 release, instead of the numeric ordinal every platform emits today. Added normalizePlanType / normalizePlan (presentationTypes.ts) and wired them into the two existing JS-side plan-parsing boundaries: the interceptor's 'purchase' action payload and the display()/preload() outcome's plan field. Numeric ordinals pass through unchanged; a known DistributionType string name is mapped to its ordinal; anything unrecognized is left untouched rather than silently dropped. Covered by unit tests (types.test.ts) and integration tests through both boundaries (presentation.integration.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| packages/purchasely/ios/PurchaselyRN.m | Major refactor: replaces preloadWithCompletion+showController with displayWithTransition:completion:, adds plyTransitionFromMap, removes nil-controller error guard, adds getBuiltInAttributes/Attribute, closeAllScreens, userLogout(BOOL), and fixes GDPR purpose normalization. The nil-controller removal is the main point of concern. |
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt | Adds OneSignal attrs, fixes runningMode default (unknown→Observer instead of Full), no-op signPromotionalOffer, getBuiltInAttributes/Attribute, closeAllScreens, userLogout(Boolean), GDPR purpose normalization widening. Changes are clean and mirror the iOS bridge. |
| packages/purchasely/src/presentationTypes.ts | Adds normalizePlanType and normalizePlan for rc.4 forward-compat string plan types. Unknown strings are returned unchanged rather than nulled — intentional but means type: string leaks through the PlanType-typed field at runtime without a console warning. |
| packages/purchasely/src/index.ts | Removes getConstants public export, adds closeAllScreens/getBuiltInAttributes/getBuiltInAttribute, userLogout(clearUserAttributes=true), changes barrel export to export type * from presentationTypes. All changes are well-aligned with the PR intent. |
| packages/purchasely/src/enums.ts | Removes ONESIGNAL_PLAYER_ID, adds ONESIGNAL_EXTERNAL_ID/ONESIGNAL_USER_ID; removes v5 running modes; adds SubscriptionSource.NONE. All changes match native bridges exactly. |
| packages/purchasely/src/tests/presentation.integration.test.ts | Substantial new test coverage: builder chain modifiers, onLoaded callback, hot-swap handlers, interceptor outcomes, rc.4 plan-type normalization. All tests are well-structured and cover the changed behaviour. |
| packages/purchasely/src/tests/startBuilder.test.ts | New file covering PurchaselyBuilder chain modifiers. Tests verify against mockConstants.runningModeFull (value 3) rather than the actual native wire value (4), a pre-existing mock discrepancy in testUtils.ts. |
| packages/purchasely/src/mocks/testUtils.ts | Updated mock constants: removes v5 running modes, adds sourceNone, oneSignalExternalId/userId, getBuiltInAttributes/Attribute and closeAllScreens mocks. runningModeFull remains 3 (pre-existing discrepancy vs. native value of 4). |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant JS as JS (presentation.ts)
participant iOS as iOS Bridge (PurchaselyRN.m)
participant SDK as Native SDK (rc.3)
JS->>iOS: displayPresentation(requestId, transition, payload)
iOS->>iOS: plyTransitionFromMap(transition)
iOS->>SDK: [request displayWithTransition:nativeTransition completion:onFetchCompletion]
SDK-->>iOS: onFetchCompletion(presentation)
iOS->>iOS: "kPresentationsByRequest[requestId] = presentation"
iOS->>iOS: "presentedPresentationViewController = presentation.controller"
iOS->>JS: emit PRESENTED
Note over iOS: OLD: nil controller → emitDismissed(err)<br/>NEW: nil controller silent (guard removed)
SDK-->>iOS: onDismissed(outcome)
iOS->>JS: emit DISMISSED
JS->>JS: normalizePlan(event.plan)
JS-->>JS: display() resolves with PLYPresentationOutcome
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant JS as JS (presentation.ts)
participant iOS as iOS Bridge (PurchaselyRN.m)
participant SDK as Native SDK (rc.3)
JS->>iOS: displayPresentation(requestId, transition, payload)
iOS->>iOS: plyTransitionFromMap(transition)
iOS->>SDK: [request displayWithTransition:nativeTransition completion:onFetchCompletion]
SDK-->>iOS: onFetchCompletion(presentation)
iOS->>iOS: "kPresentationsByRequest[requestId] = presentation"
iOS->>iOS: "presentedPresentationViewController = presentation.controller"
iOS->>JS: emit PRESENTED
Note over iOS: OLD: nil controller → emitDismissed(err)<br/>NEW: nil controller silent (guard removed)
SDK-->>iOS: onDismissed(outcome)
iOS->>JS: emit DISMISSED
JS->>JS: normalizePlan(event.plan)
JS-->>JS: display() resolves with PLYPresentationOutcome
Comments Outside Diff (1)
-
packages/purchasely/ios/PurchaselyRN.m, line 1442-1460 (link)Nil-controller error path silently removed
The old
onFetchCompletionblock explicitly checkedpresentation.controller == nil, then calledemitDismissed(err)— keeping the JS lifecycle in a consistent state (dismiss event paired with every presented event). That guard was removed with the reasoning thatdisplayWithTransition:completion:guarantees the view is already handed to UIKit before the callback fires.That assumption is valid for the current rc.3 SDK, but if any future SDK version calls the completion in an error scenario with a nil controller (e.g., a presentation that fails post-fetch but before display), the JS side will receive
onPresentedwith no matchingonDismissedandpresentedPresentationViewControllerwill silently be set to nil — which would break a subsequentrequest.close()call on iOS that relies on this property. Consider adding a lightweight guard (if (presentation.controller == nil) { emitDismissed(someError); return; }) after settingpresentedPresentationViewControllerto preserve the invariant without blocking current behaviour.Prompt To Fix With AI
This is a comment left during a code review. Path: packages/purchasely/ios/PurchaselyRN.m Line: 1442-1460 Comment: **Nil-controller error path silently removed** The old `onFetchCompletion` block explicitly checked `presentation.controller == nil`, then called `emitDismissed(err)` — keeping the JS lifecycle in a consistent state (dismiss event paired with every presented event). That guard was removed with the reasoning that `displayWithTransition:completion:` guarantees the view is already handed to UIKit before the callback fires. That assumption is valid for the current rc.3 SDK, but if any future SDK version calls the completion in an error scenario with a nil controller (e.g., a presentation that fails post-fetch but before display), the JS side will receive `onPresented` with no matching `onDismissed` and `presentedPresentationViewController` will silently be set to nil — which would break a subsequent `request.close()` call on iOS that relies on this property. Consider adding a lightweight guard (`if (presentation.controller == nil) { emitDismissed(someError); return; }`) after setting `presentedPresentationViewController` to preserve the invariant without blocking current behaviour. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
packages/purchasely/ios/PurchaselyRN.m:1442-1460
**Nil-controller error path silently removed**
The old `onFetchCompletion` block explicitly checked `presentation.controller == nil`, then called `emitDismissed(err)` — keeping the JS lifecycle in a consistent state (dismiss event paired with every presented event). That guard was removed with the reasoning that `displayWithTransition:completion:` guarantees the view is already handed to UIKit before the callback fires.
That assumption is valid for the current rc.3 SDK, but if any future SDK version calls the completion in an error scenario with a nil controller (e.g., a presentation that fails post-fetch but before display), the JS side will receive `onPresented` with no matching `onDismissed` and `presentedPresentationViewController` will silently be set to nil — which would break a subsequent `request.close()` call on iOS that relies on this property. Consider adding a lightweight guard (`if (presentation.controller == nil) { emitDismissed(someError); return; }`) after setting `presentedPresentationViewController` to preserve the invariant without blocking current behaviour.
### Issue 2 of 3
packages/purchasely/src/presentationTypes.ts:275-305
**Unknown string `type` passes through silently as a non-`PlanType` value**
`normalizePlanType` returns `null` for any string not in `PLAN_TYPE_NAME_MAP`, and `normalizePlan` then returns the raw plan object unchanged — so `plan.type` remains a `string` at runtime while TypeScript thinks it's a `PlanType` number. Any consumer that pattern-matches on `plan.type === PlanType.PLAN_TYPE_AUTO_RENEWING_SUBSCRIPTION` would silently fail to match, with no indication of why.
This is the documented intent ("left as-is rather than silently dropped"), but without a `console.warn` for unrecognized strings, debugging this on a future Android rc.4+ device where a new `DistributionType` member appears would be hard. A one-line `console.warn('[Purchasely] normalizePlanType: unrecognized type string', value)` before returning `null` would preserve the pass-through behaviour while making the gap visible.
### Issue 3 of 3
packages/purchasely/src/__mocks__/testUtils.ts:47-48
**`runningModeFull` mock value (3) doesn't match the real native wire value (4)**
Both the iOS bridge (`PLYRNRunningModeFull = 4`) and the Android bridge (`const runningModeFull = 4`) export ordinal `4` for Full mode. The mock has `runningModeFull: 3`, which means all JS unit tests that check `RunningMode.FULL` (including the new `startBuilder.test.ts`) are verifying against an incorrect wire value. A production device would send `4` to the native `start()`, not `3`. If a regression ever sent `3` instead of `4`, the tests would pass but the native bridge's `else → Observer` fallback would silently activate.
Reviews (1): Last reviewed commit: "feat: tolerate Android's upcoming Distri..." | Re-trigger Greptile
There was a problem hiding this comment.
Pull request overview
This PR remediates v6 cross-platform parity gaps between the React Native TypeScript surface and the pinned iOS/Android native SDKs (6.0.0-rc.3), updating bridge behavior, public types/enums, and documentation while expanding test coverage to lock in the corrected contract.
Changes:
- Aligns iOS/Android bridge behavior and constants (e.g., transitions via
PLYTransition, running mode fallback, newsourceNone, OneSignal attribute keys,closeAllScreens, built-in attribute reads). - Hardens JS-side plan parsing to tolerate upcoming Android
plan.typestring values vianormalizePlanType/normalizePlan. - Updates v6 migration documentation and adds/extends Jest + native unit tests to cover the new/changed surface.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/purchasely/src/types.ts | Reactivates Android-only subscription revenue/duration fields as optional + documents platform asymmetry. |
| packages/purchasely/src/presentationTypes.ts | Adds internal plan-type normalization helpers and clarifies internal-only exports. |
| packages/purchasely/src/presentation.ts | Normalizes outcome.plan to tolerate upcoming Android string type values. |
| packages/purchasely/src/interfaces.ts | Updates Constants keys (OneSignal changes; removes v5 running mode constants). |
| packages/purchasely/src/interceptor.ts | Normalizes intercepted purchase payload plan typing for rc.4 hardening. |
| packages/purchasely/src/index.ts | Removes public getConstants, adds APIs (closeAllScreens, built-in attribute reads, logout param), and adjusts barrel exports. |
| packages/purchasely/src/enums.ts | Adds SubscriptionSource.NONE and replaces removed OneSignal constant with new OneSignal keys. |
| packages/purchasely/src/tests/types.test.ts | Adds tests for optional Android-only subscription fields + plan normalization helpers. |
| packages/purchasely/src/tests/startBuilder.test.ts | New direct unit tests for PurchaselyBuilder chain modifiers and start argument shaping. |
| packages/purchasely/src/tests/presentation.integration.test.ts | Expands integration coverage for builder options, callbacks, interceptors, and plan normalization. |
| packages/purchasely/src/tests/index.test.ts | Updates public API expectations (no getConstants, no barrel export for internal helper, new APIs). |
| packages/purchasely/src/tests/enums.test.ts | Updates enum expectations for SubscriptionSource.NONE and OneSignal enum replacement. |
| packages/purchasely/src/mocks/testUtils.ts | Updates mocked constants and native module mocks for new/removed constants and APIs. |
| packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m | Updates iOS native tests for constants changes (sourceNone, OneSignal keys, running modes). |
| packages/purchasely/ios/PurchaselyRN.m | Implements transition parsing + display via displayWithTransition, adds APIs, updates constants, and widens GDPR purpose mapping acceptance. |
| packages/purchasely/ios/Purchasely.xcodeproj/project.pbxproj | Removes deleted RCTConvert_PurchaselyRN.m references from Xcode project. |
| packages/purchasely/ios/Classes/RCTConvert_PurchaselyRN.m | Deletes dead legacy RCTConvert file. |
| packages/purchasely/ios/Classes/Hybrid/PLYPlan+Hybrid.m | Ensures hasIntroductoryPrice is always emitted as a boolean to match TS contract. |
| packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt | Updates Android native unit tests for constants changes and GDPR purpose mapping tolerance. |
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt | Aligns running mode fallback, adds APIs, changes Android promo signing to no-op success, and widens GDPR purpose mapping acceptance. |
| MIGRATION-v6.md | Documents breaking changes, new APIs, iOS transition dimension ceiling, and rc.4 plan.type forward compatibility. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Context
Cross-platform parity audit of the v6 React Native bridge against the pinned native SDKs (iOS/Android 6.0.0-rc.3). The audit compared the TS surface, the iOS Objective-C bridge, and the Android Kotlin bridge for behavioral divergences, dead v5 residue, payload/enum mismatches, and GDPR wire-format correctness. This PR remediates every confirmed finding, ports the audit test additions, and documents each breaking change in
MIGRATION-v6.md.Every iOS selector touched here was verified to exist in the pinned rc.3 header (
Purchasely-Swift.h, checksumffb024…, the buildPodfile.lockresolves to). No native pins were bumped.Fixes by ID
signPromotionalOfferon Android was a permanentreject("Not supported on Android"). Now resolves as a no-op success (no StoreKit equivalent exists on Android). No TSPlatform.OSguard added (transparent pass-through); JSDoc marks it iOS-only;MIGRATION-v6.mdcorrected (moved out of the "unchanged" list with an explicit note).display()no longer hardcodesshowController:type:PLYUIControllerTypeProductPage. It now parses the fullPLYTransition(typefullScreen/push/modal/drawer/popin/inlinePaywall,dismissible,backgroundColors, percentageheight) and displays through the SDK's owndisplayWithTransition:completion:. See behavior note below on the iOS dimension ceiling.closeAllScreens()(TS + iOSRCT_EXPORT_METHOD+ Android@ReactMethod).constantsToExportnow includessourceNone; TS gainsSubscriptionSource.NONE.start()now defaults an unknownrunningModeordinal to Observer, aligned with iOS.PLYPresentationPlan(basePlanId/storeOfferIdAndroid-only;offerIdcross-platform but distinct id spaces). No behavior change.PLYPlan+Hybridalways emitshasIntroductoryPriceas a boolean (it was deleted from the dict for free-trial plans, breaking the non-optional TS contract);hasFreeTrialstill set.RCTConvert_PurchaselyRN.mdeleted + 4project.pbxprojreferences removed (plutil -lintOK; podspec uses a glob, no stale explicit reference).startWithAPIKeyremoved (impl + any reference; the only remaining mention is a negative-assertion test).runningModeTransactionOnly/runningModePaywallObserverconstants removed (iOS, Android,interfaces.ts, both native tests). Observer/Full keep their original ordinals (1/4).onesignalPlayerId/Attributes.ONESIGNAL_PLAYER_IDremoved;oneSignalExternalId+oneSignalUserIdadded. iOS uses the native symbols (PLYAttributeOneSignalExternalId/…UserId), never hardcoded ints —PLYAttributeOneSignalPlayerIdstill exists in the pinned header (its native removal ships separately) but is no longer referenced. Android usesAttribute.ONESIGNAL_*.ordinal.getConstants()removed from the publicPurchaselyexport (it leaked a raw 60+-field numeric blob). The enums still readNativeModules.Purchasely.getConstants()internally, so nothing else changes.purchaseResultFromOrdinalno longer re-exported from the package root (export type *drops the runtime value); the SDK's own internal use is unaffected.cumulatedRevenuesInUSD+subscriptionDurationIn{Days,Weeks,Months}reactivated as optional with Android-only JSDoc (verified present in Android's nativePLYSubscription; iOSPLYSubscription+Hybridnever emits them and was not touched).userLogout(clearUserAttributes = true)now forwarded to both natives (userLogout:(BOOL)iOS,userLogout(Boolean)Android) instead of being hardcoded/omitted.getBuiltInAttributes()/getBuiltInAttribute(key)exposed (TS + both natives). iOS usesgetBuiltInAttributes/getBuiltInAttributeWith:.typeparsing tolerates both the numeric ordinal (today) and Android's upcomingDistributionTypestring at both JS transform points (theinterceptAction('purchase', …)payload and thedisplay()/preload()outcome'splan), vianormalizePlan/normalizePlanType.Breaking changes (see
MIGRATION-v6.md)Purchasely.getConstants()no longer exposed publicly (PAR-12) — use the typed enums.onesignalPlayerId/Attributes.ONESIGNAL_PLAYER_IDremoved (ENM-04) — useONESIGNAL_EXTERNAL_ID/ONESIGNAL_USER_ID.runningModeTransactionOnly/runningModePaywallObserverconstants removed (ENM-06).purchaseResultFromOrdinalno longer re-exported from the package root (PAR-14, was documented@internal).Behavior notes
signPromotionalOfferon Android is a no-op success — don't rely on the shape of the resolved value there.0–1 heightPercentageto Objective-C. So on iOSheight: { type: 'percentage', value },type,dismissible, andbackgroundColorsare honored;height: { type: 'pixel' }andwidth(any unit) are accepted but ignored with a console warning (native content-hugging sizing applies). Android honorswidth/heightin both units. Documented inMIGRATION-v6.md.presentation.transition(iOSdisplayWithTransition:nil, Androiddisplay(transition = null)). Note this replaces iOS's previous hardcoded full-screen ProductPage — the new behavior matches the SDK's owndisplay()and Android.runningModeunknown-ordinal fallback is now Observer on both platforms.New APIs
closeAllScreens()·SubscriptionSource.NONE·getBuiltInAttributes()/getBuiltInAttribute(key)·userLogout(clearUserAttributes?)·Attributes.ONESIGNAL_EXTERNAL_ID/Attributes.ONESIGNAL_USER_ID.GDPR trace (REC-15 / ENM-12)
Verdict, counter-verified on both platforms:
third-party-integration); the other Purchasely SDKs useSCREAMING_SNAKE_CASEplural (e.g.THIRD_PARTY_INTEGRATIONS).PLYDataProcessingPurposeenum objects and pass anNSSet/Setof those enums toPurchasely.revokeDataProcessingConsent(For:). The raw string is consumed at the bridge boundary; only the resolved native enum crosses it. Confirmed against the native enum members on both sides (iOS:allNonEssentials/analytics/identifiedAnalytics/campaigns/personalization/thirdPartyIntegrations; Android: same set underio.purchasely.ext.PLYDataProcessingPurpose).iOS mapPurposesFromStrings:,Android mapPurposesFromReadableArray): normalize to lowercase,_→-, and singularizethird-party-integrations→third-party-integration, so both the kebab and the SCREAMING_SNAKE plural conventions resolve to the same native purpose. Unknown strings are silently ignored (never forwarded). All 6 purposes covered on both platforms.revokeDataProcessingConsent accepts both kebab-case and SCREAMING_SNAKE_CASE wire strings).Known issues (out of scope, distinct bug)
backgroundColor/progressColorare never wired. The JS builder forwards them in the payload and Android consumes them, but iOSapplyPresentationDisplayOptionsonly readsdisplayCloseButton/displayBackButton— the two color options are silently dropped on iOS. Pre-existing; should be fixed separately.Tests
yarn test). Audit test additions ported (Étape 0).yarn typecheck).eslint --no-eslintrc -c .eslintrc.js packages/purchasely/srcreports 0 errors. The rootyarn lintscript fails only in the nested-worktree setup (two.eslintrc.jsin the cascade resolve@typescript-eslintfrom twonode_modules); this is a pre-existing environment issue reproducible on the base tree, not a code regression.🤖 Generated with Claude Code