refactor(ios)!: port the React Native bridge module to Swift - #298
Draft
kherembourg wants to merge 33 commits into
Draft
refactor(ios)!: port the React Native bridge module to Swift#298kherembourg wants to merge 33 commits into
kherembourg wants to merge 33 commits into
Conversation
Two-phase plan: the Hybrid serialization categories first, then the 2086-line module behind a thin RCT_EXTERN_METHOD shim. Records the React Native constraints that shape it (the shim is text-parsed, so a signature mismatch fails at run time), the -import-underlying-module build fact that makes react-native-purchasely-Bridging-Header.h load-bearing, and why a TurboModule is a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reviewers attacked the first draft. Verified and applied: - The NSNull rule was wrong and would have broken the client contract: the bridge omits keys per field and types.ts documents it. Now a per-field policy. - 60 exported constants, not 34. 34 dispatch_async, not 40. 15 Hybrid files. Test suites are 535/785 lines, not 330/265 (CLAUDE.md is stale). E2E is T1-T30. - RCT_EXTERN_REMAP_METHOD is not public in RN 0.86, so the 7 RCT_REMAP_METHOD sites get an explicit @objc(selector:) instead. - Nil reaches a Swift thunk unchecked in Release (the check is behind RCT_DEBUG), so every object parameter must be Optional. - A Swift enum in [String: Any] is dropped by the bridge: .rawValue everywhere, and the snapshots assert value types. - NSLock is not reentrant and @synchronized is; closePresentation has two sequential locked blocks, so scope must be preserved per block. - Blanket [weak self] would settle the native promise twice; making the reject helper static removes the whole question. - ios/Serialization/ is outside the podspec globs -> ios/Classes/. - The parity gate uses RCTParseMethodSignature and asserts JS names, not RCTModuleMethod + RCTAssert; the Node fallback is cut. - 81c5a65 was a main-queue module-holder deadlock, not duplicate registration. - sharedViewController, shouldReopenPaywall and presentedPresentationViewController are dead: deleted, not ported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Goal 3's swiftinterface citations now come from the plain arm64-apple-ios-simulator.swiftinterface (PLYPromoOffer exposes only vendorId/storeOfferId; PLYTransition.init 994, drawer/popin 1009-1010, PLYDimension 1150, from(screenId:) 756). Confirmed 5 of the 7 RCT_REMAP_METHOD sites already match their JS name, so exactly two selectors change. Confirmed the 18 reject:with: call sites are the only instance member the strongly capturing blocks touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1's serializer key lists now come from the five .m files, including the note that PLYPlan's five offer* keys are deliberate safe defaults the port must not try to fix (PLYTagHelper is private and takes an internal type, so Swift cannot reach it either). Task 4 carries the real hex parser and the forms it actually accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two adversarial reviews found 12 blocking errors in the first draft, all one root cause: Swift written from memory for SDK calls instead of read off arm64-apple-ios-simulator.swiftinterface. Fixed, each verified: - requiresMainQueueSetup returns YES (PurchaselyRN.m:1447). The draft hardcoded false, violating its own Global Constraint 11. - start takes 10 arguments beginning with an apiKey string, and its resolve segment is named initialized:. The draft invented a 3-argument options-dict signature. - purchaseResultOrdinal returns nil for .none; the draft returned non-optional and mapped .none to 1, turning no-purchase into cancelled. - PLYLogLevel is nested under PLYLogger and set via setLogLevel(_:), not an assignable property. PLYAttribute and PLYThemeMode are nested too. - The legal-basis mapper takes a String and returns PLYDataProcessingLegalBasis; PLYLegalBasis does not exist. - isEligibleForIntroductoryOffer recursed into itself; the SDK method is isUserEligibleForIntroductoryOffer and is already @objc, so the wrapper is deleted and the one caller repointed. - No model type has init(); fixtures are decoded from JSON, which also gives the populated case the spec requires. - asDictionary imports as a method, not a property, so the Swift replacement is a method and the contract tests stay frozen. - PLYProduct+Hybrid.m imports PLYPlan+Hybrid.h: phase 1 would not build. - nonce/timestamp guards are dead code; both keys are always present. - commitmentInfo guards on count > 0, so it needs !isEmpty, not if let. - The gate had a tuple-label compile error, and the negative test would have orphaned a method body. Also: Global Constraint 0 (never write an unread SDK symbol) plus a verified-symbol table, exclusive method ownership (synchronize was in two tasks), a 60-value constants assertion instead of a types-only one, the log helper moved to Task 8 to drop a TODO round-trip, the @objc public to internal cleanup assigned to Task 14, and a Task 16 for CI/E2E acceptance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Snapshots the key sets, the per-field absence policy and the value types the Objective-C categories produce today, on a populated and a sparse fixture each, so the Swift port cannot change them silently. The absence policy is the live hazard: Swift removes a key assigned nil, and types.ts documents to clients which keys the iOS bridge omits. Fixtures are decoded from JSON because no SDK model type has a no-argument initializer. PLYSubscription is not covered: its init(from:) resolves .product via the SDK-internal ProductRepository.shared, which is empty and unseedable from outside the module in a unit test, so decoding always throws couldntFindProduct. Reported for Task 3.
One branch and one pull request instead of two, per the user's instruction. PLYSubscription is not ported: its init(from:) needs ProductRepository, which is internal with no injection seam, so no fixture can be built and no E2E test covers userSubscriptions either. Porting it would be the only change in this migration with no automated coverage at any level, on the one serializer whose absence policy types.ts documents to clients. It stays Objective-C behind a compile-time dependency the three iOS build jobs check. Also records the two gate limitations Task 1's review found, so no later task closes them by relaxing an assertion.
…s and the mappers An adversarial review of the gate found five holes a wrong port walks through: the nested plan dictionaries were unpinned, no assertion compared an exact key set, both billing-plan-type mappers had no coverage at all, and the coalesced offer defaults were asserted present but never valued. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two FOUNDATION_EXPORT C mappers become @objc static members, since a free Swift function cannot be @objc, and all six Objective-C call sites move with them — PLYProduct+Hybrid.m imported that header too. asDictionary stays a method, not a property, so its call shape is identical in Objective-C and Swift and the contract tests are untouched. The intro-offer eligibility category method is deleted rather than ported: the SDK's own isUserEligibleForIntroductoryOffer is already @objc, so the one caller now uses it directly. Two additions beyond the plan snippet, both required to keep frozen files green without editing them: - SerializationContractTests.swift (Task 1's gate) calls PLYBillingPlanTypeToRNString/FromRNString as top-level Swift functions, not as PLYPlan static members. Added thin free-function aliases in PLYPlan+Bridge.swift that delegate to the @objc static members, so both the Swift gate test and the Objective-C call sites share one implementation. - PLYSubscription+Hybrid.m (frozen per Amendment A2, stays Objective-C) called self.plan.asDictionary via the now-deleted PLYPlan+Hybrid.h. Added a local @interface PLYPlan (BridgeSerialization) forward declaration in that .m file per the orchestrator's seam guidance — the Swift extension supplies the method body at link time, avoiding the react_native_purchasely-Swift.h import cycle.
PLYProduct, PLYOfferSignature and PLYPresentationPlan move to Swift extensions under Classes/Serialization/, translated key for key from their Objective-C categories. PLYOfferSignature's nonce/timestamp guards were dead code on non-optional values (Constraint 6), so both keys are unconditional. PLYPresentationPlan.default is a Swift Bool on the SDK interface, not an enum, so it stays a boxed BOOL rather than .rawValue as an earlier plan draft called for (Global Constraint 0: the interface wins). Amendment A2: PLYSubscription is NOT ported and stays Objective-C — ProductRepository has no injection seam, so no fixture can exercise it. PLYSubscription+Hybrid.m now forward-declares PLYPlan and PLYProduct's asDictionary the same way Task 2 did for PLYPlan, since both are now Swift extension methods supplied at link time.
The UIViewController (Hybrid) category is deleted, not ported: it has no
caller ([presentation close] is the PLYPresentation protocol method), and
a library that adds a -close selector to every view controller in the
host app is a liability with no user.
One deliberate behaviour change in the hex parser: the Objective-C version
cast scanHexInt:'s result to (void), so a malformed 6-character string such
as #GGGGGG scanned to 0 and painted opaque black. The Swift version returns
nil and the caller falls back. Every well-formed input parses identically.
Adds s.swift_versions to the podspec, now that the pod is Swift-majority.
Per Orchestrator amendment A2, PLYSubscription+Hybrid.{h,m} and a trimmed
Purchasely_Hybrid.h (now importing only PLYSubscription+Hybrid.h) stay in
Classes/Hybrid/ — PLYSubscription is not ported.
Test file: new ios/PurchaselyTests/UIColorPLYHexTests.swift, not an append
to SerializationContractTests.swift as the task step literally said — that
file is a frozen gate outside this task's Files list, and Tasks 2-3 never
touched it either.
…uild-time one The Task 2 review caught a false claim in my own amendment. An Objective-C message send to a Swift @objc extension method is runtime dispatch and creates no link-time reference, and the hand-written forward declaration is never checked against the Swift symbol. Dropping @objc would keep every build green and crash the first userSubscriptions call with an unrecognized selector. So the tie is a selector-presence test, and @objc on the two asDictionary methods is permanent rather than a phase-1 scaffold.
An Objective-C send to a Swift @objc extension method is runtime dispatch, so dropping @objc from PLYPlan.asDictionary would keep every build green and crash the first userSubscriptions call. A selector-presence test is the only thing that catches it, and two comments claimed the opposite. Also renames a local that shadowed PLYPlan.period, rejects the leading-sign hex input the ported parser started accepting, and covers all four channels of the 8-digit branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reads React Native's own __rct_export__ table and asserts the 63 JS method names, the module name, the 60 constant keys with numeric values, and the 11 event names. The shim is parsed as text, so a Swift signature that disagrees with it fails only at run time in a client app; this turns that into a CI failure. Verified it fails when an export is removed and when a selector is renamed.
Done before the implementation moves, so the port of the module runs under an unchanged Swift test file. XCTestCase, not Swift Testing: a CocoaPods test spec requires it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sharedViewController's only reader was its own accessor and a test; shouldReopenPaywall was written once and never read; and presentedPresentationViewController was written twice and never read. Deleting them removes the trickiest ownership question of the Swift port (class-wide mutable storage whose getter recreates a controller after its setter receives nil) instead of translating it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fix lot tried to prove the seam and could not, which is the useful result: PurchaselyRN.m still calls asDictionary at 15 sites through the generated Swift header, so dropping @objc fails the build today rather than passing it. The runtime-only seam starts at Task 14, which now owns the red-test/green-build mutation proof.
The export gate locked the 63 JS names but never asserted objcName, so a changed selector or argument count passed it — which is the one failure the text-parsed shim cannot catch at build time. Snapshots the Objective-C selectors now so Task 14 has a before-image. Also corrects the sibling @objc-is-temporary comment that would have steered Task 14 into the unrecognized-selector crash A2 exists to prevent, and restores two assertions the test port had quietly weakened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Named PurchaselyBridge and carrying no export macro until the swap commit, so React Native never sees two classes claiming the JS module name Purchasely. Holds the shared state behind a scoped NSLock helper (NSLock is not reentrant and @synchronized was), the 60 constants, the 11 events, and a static reject helper — static so the 18 closures that use it capture nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skeleton implemented the 4-argument PLYUserAttributeDelegate method while the Objective-C implements only the 5-argument one, so the Swift would have dropped processingLegalBasis from USER_ATTRIBUTE_SET_LISTENER — a documented React Native API feature. Also normalises the exported-selector snapshot to the bare selector so the gate survives the Task 14 rewrite without being regenerated, which is the one response that would swallow a real selector change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 9 of the Swift bridge migration plan: PurchaselyRN+Lifecycle.swift carries start, purchasePerformed, setLogLevel, setThemeMode, userLogin, userLogout, isAnonymous, getAnonymousUserId, handleDeeplink, readyToOpenDeeplink, allowDeeplink, allowCampaigns, setLanguage, setDebugMode, userDidConsumeSubscriptionContent, revokeDataProcessingConsent (with its mapPurposesFromStrings mapper) and synchronize, as an extension on the still-unregistered PurchaselyBridge skeleton. BridgeLifecycleTests.swift covers the mapper and the three guard branches (unknown log level, unknown theme mode, nil deeplink) that are observable without a live SDK; the remaining methods are single SDK calls left to E2E, per the plan's task shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
React Native's nil check sits behind #if RCT_DEBUG, so in a client Release build nil passes straight into the Swift thunk. The attributes port declared 21 parameters non-Optional because the Objective-C annotated them _Nonnull — documentation, not a Release guarantee — and each one would have trapped in a client app. Selectors are unchanged, so the shim still matches. Also restores NSLog at the two sites the port had rerouted through RCTLogWarn, which a Release build filters out, and covers the two numeric traps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Port PurchaselyRN.m:972-1240 (Task 11) to packages/purchasely/ios/PurchaselyRN+Products.swift: purchaseWithPlanVendorId, restoreAllProducts, silentRestoreAllProducts, allProducts, productWithIdentifier, planWithIdentifier, userSubscriptions, userSubscriptionsHistory, setDynamicOffering, getDynamicOfferings, removeDynamicOffering, clearDynamicOfferings, signPromotionalOffer, isEligibleForIntroOffer — 14 @objc exported methods on PurchaselyBridge, no export macro yet (Task 14 renames the class). Every object-typed parameter is Optional per Global Constraint 4, regardless of the ObjC _Nonnull annotation; the two REMAP selectors that changed shape (restoreAllProducts, silentRestoreAllProducts) keep the exact @objc selector the plan specifies. The promo-offer lookup inside purchaseWithPlanVendorId and the getDynamicOfferings dictionary mapping are extracted as static helpers and unit tested; every other method here is a single SDK call and is left to E2E, per the shared task shape. BridgeProductsTests.swift: 5 new tests, all failing first against the skeleton (no `storeOfferId`/`offeringDictionary` members), then passing after the extension was written. Full suite: 155 tests, 0 failures. synchronize stays Task 9's; not redeclared here.
Task 12 of the Swift bridge migration plan. Ports PurchaselyRN.m's
preload/display/close/back/BYOS presentation surface, the presentation
helpers (presentationToMap, presentationErrorToMap, closeReasonToRNString,
transition parsing, presentationBuilder), the 5 static members
PurchaselyView.swift calls (zero diff there), webRedemptionBody, and the
3 delegate conformance bodies Task 8 stubbed (eventTriggered,
onUserAttributeSet/onUserAttributeRemoved, webRedemptionCompleted).
Removes Task 8's 4 stub delegate methods from PurchaselyRN.swift so this
extension's real bodies can replace them (Swift rejects the redeclaration
otherwise) — required by the task text ("Replace Task 8's stubs"), not a
drive-by; the Files list named two files, this makes it three.
180 tests green (27 new in BridgePresentationsTests), including the
closePresentation two-block-lock non-deadlock proof.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Objective-C treated the five array attribute setters two different ways — two coerced each element, three rejected the whole array — and the port flattened both into a per-element drop, which sets a partial array where the Objective-C either coerced or set nothing. User attributes drive audience targeting, so a partial array shows a client the wrong paywall. Also omits a nil requestId from the presentation event bodies instead of sending an empty string, restores the log prefixes, and covers emitPresentationDismissed, whose payload every display() promise resolves from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PurchaselyRN.m drops from 2086 lines to an export shim plus one non-variadic log wrapper, because RCTLogWarn is a variadic macro Swift cannot see. PurchaselyRN.h, PLYTransitionFactory.swift (its only reason was that Objective-C cannot build a PLYTransition) and the stale Purchasely.xcodeproj are deleted. Two selectors change and neither is JS-visible: restoreAllProducts and silentRestoreAllProducts now carry selectors whose first segment is their JS name, because RCT_EXTERN_REMAP_METHOD is not public in RN 0.86. Gate verified with teeth twice on the Swift side: removing a shim line fails the JS-name and count tests, and renaming a selector segment fails both those and the selector-resolution test. Added BridgeSelectorResolutionTests.swift (import React resolved fine in the test target, so no Objective-C fallback was needed) to prove every shim selector actually resolves on PurchaselyRN via RCTParseMethodSignature. Reduced PLYOfferSignature+Bridge.swift, PLYPresentationPlan+Bridge.swift and UIColor+PLYHex.swift from @objc public to internal now that no Objective-C caller remains. PLYPlan.asDictionary and PLYProduct.asDictionary stay @objc public permanently per amendment A2 (PLYSubscription+Hybrid.m's forward declaration is the sole, runtime-only consumer). Demonstrated the amendment's mutation proof: dropping @objc from PLYPlan.asDictionary left the build green (unit-test target and the example app both still compiled and linked) while SerializationContractTests.testPlanAndProductRespondToAsDictionarySelector went red; reverted.
The selector-resolution test passed vacuously on an empty export table, the presentation-dismissed payload was pinned for four of its six keys, and six of the seven event bodies carrying the requestId omission had no test — including the ones display() actually resolves from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also corrects two stale test line counts that predate this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge sections explained how to add a shim line but never stated the rule that makes it work: the Swift method needs an explicit @objc(selector:) whose first segment is the JS name. Also adds the view manager, the second text-parsed shim in the pod, and replaces the two-file iOS test list with the real suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A frozen main queue produced only "preload() did not settle", which cost a long investigation across CI artifacts to localise. A 5-second sample taken before the kill names the blocked frames directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 and phase 2 of the iOS Objective-C to Swift bridge migration, MOB-466.
Spec:
docs/superpowers/specs/2026-09-08-ios-bridge-swift-migration-design.mdPlan:
docs/superpowers/plans/2026-09-08-ios-bridge-swift-migration.md(16 tasks; the"Orchestrator amendments" block at the top records every decision taken during execution)
2086 lines of Objective-C become six Swift files plus a 63-line export shim. No JavaScript API
change: same module name, same 63 method names, same 60 constants with the same values, same 11
events, same dictionary keys and the same per-field absence policy. Nothing under
src/is touched.Why the shim still exists
React Native discovers a legacy module through class methods that only
RCT_EXTERN_REMAP_MODULEandRCT_EXTERN_METHODgenerate, and Swift cannot emit them. SoPurchaselyRN.msurvives asRCT_EXTERN_REMAP_MODULEplus 63RCT_EXTERN_METHODlines and thePLYRNLogWarnwrapper(
RCTLogWarnis a variadic macro Swift cannot call).That shim is parsed as text at registration, not linked. A Swift
@objcsignature that disagreeswith its
RCT_EXTERN_METHODline produces no compile error and fails at run time in a client app with"method not found". No build job catches it, so three gates do:
BridgeExportContractTestsBridgeSelectorResolutionTestsSerializationContractTestsBoth contract gates were written against the Objective-C output before any code moved, and each was
shown failing before it counted — on a removed export, on a renamed selector, on a changed argument
count, and on an emptied export table.
Two selectors change; neither is JS-visible
RCT_EXTERN_REMAP_METHODis not public in RN 0.86 (RCTBridgeModule.h:310-323), so the shim cannotremap.
restoreAllProductsandsilentRestoreAllProductstherefore take selectors whose first segmentis their JS name:
The other 61 were verified byte-for-byte against their Swift
@objc(...)annotations, mechanically andwithout sampling.
PLYSubscription stays Objective-C, on purpose
This is the one deliberate departure from the plan.
PLYSubscription.init(from:)resolves.productthrough
ProductRepository.shared, which isinternalwith no injection seam — the SDK's own sourcecarries
#warning("This decoding depends on ProductRepository, which cannot be injected"). No fixturecan be built, and
integration_test/never callsuserSubscriptionseither, so a port would have beenthe only change here with no automated coverage at any level — on the one serializer whose absence
policy
types.ts:138-141documents to clients.Classes/Hybrid/PLYSubscription+Hybrid.{h,m}therefore remains, reaching the Swift serializers througha forward declaration. Because an Objective-C message send to a Swift
@objcextension method isruntime dispatch with no link-time reference,
@objconPLYPlan.asDictionaryandPLYProduct.asDictionaryis permanent, andtestPlanAndProductRespondToAsDictionarySelectoris theonly thing that catches its removal. That was verified by mutation: dropping
@objcleaves the buildgreen and turns that test red.
Follow-up: port it once the SDK gives
ProductRepositoryan injection seam, which its own TODO alreadyanticipates.
One intentional behaviour change
The hex colour parser is now uniformly stricter on malformed input. The Objective-C used
NSScanner,which returned a colour — often opaque black — for input like
"FF00ZZ","0xFFFF"or"FF 000";UInt32(_:radix:)returns nil, and a hex-digit guard makes the leading-sign case ("+12345") behavethe same way as the rest instead of being an exception. The path is client-reachable
(
map["backgroundColors"]["light"]comes from a JS argument), so a malformed colour now falls backinstead of rendering black.
Everything else is behaviour-preserving, including two things the port had changed and which were put
back: the five array attribute setters (the Objective-C coerced elements in two of them and rejected the
whole array in the other three — user attributes drive audience targeting, so a silently partial array
shows the wrong paywall), and a nil
requestId, which omits its key from the event body rather thansending an empty string.
Also deleted
PurchaselyRN.h,PLYTransitionFactory.swift(its only reason was that Objective-C cannot construct aPLYTransition), the stalepackages/purchasely/ios/Purchasely.xcodeprojthat no CI job reads, theseven
+Hybridserializer pairs, and three dead members (sharedViewController,shouldReopenPaywall,presentedPresentationViewController).Verification
USE_FRAMEWORKS=staticyarn lint/yarn typechecke2e-iosT1-T30 is the real acceptance criterion — Jest mocks the native module, so it proves nothingabout a text-parsed shim reaching JS.
Release vehicle 6.2.0, not a
6.1.xpatch: no client-visible feature, but the whole native layer ofone platform changes.