Repository navigation
v1.76.0
Added
-
A custom paywall screen can pass its purchase transaction to
complete()(#287).CustomPaywallScreenProps.completetakes an optional
transaction: { originalTransactionIdentifier?, purchaseToken?, productId?, restored? }: the purchase's store transaction id (iOS original transaction
id, Android Play order id), the Android purchase token, the store product id,
andrestored: truefor a purchase that only restored access. Both
surfaces, thePaywallHostModal and the inlinePaywallstep, forward it to
theobserver'send(), so a purchase the SDK never saw is attributed to its
presentation. Sanitized first: an id that is not a non-empty string, a
restoredthat is not exactlytrue, and every other field are dropped, and
nothing is sent when nothing survives; a transaction that throws when read (a
throwing getter, a revokedProxy) is dropped and never stops the end, so
the observer still gets it once andpresent()still resolves. Sent only
when the screen reportsstatus: "purchased"; with any other status it is
dropped with a warning, even when a recorded purchase makes the end
purchased. When the presentation already recorded an SDK purchase, that
purchase wins: the screen's ids are added to it only when the screen's
productIdis present and equals the recorded slot's store id,restoredis
never added, and in every other case (noproductId, a different one, or a
recorded slot with no store id on this platform) the screen's ids are dropped
with a warning. A stalecompletefrom an ended presentation stays a no-op,
transaction included.
usePaywallHost().completetakes the transaction as an optional second
argument.PresentResultis unchanged:present()never resolves with it.
Omitting it behaves exactly as before. -
PaywallProviderobserverprop — an optionalPaywallObservertold
about every paywall presentation, so a host can send it to its own analytics
(#286).start(info)runs oncepresent()accepts (never for an
unknown-momentoralready-presentingrefusal),handle.shown()on the
host's acknowledgement, andhandle.end(outcome)exactly once with the
reconciled result — every later error asstatus: "error"with itsreason.
An elements-mode purchase carriestransaction.productId, the purchased
slot's id for the running platform. The inlinePaywallstep reports through
the same observer withsurface: "paywall_step", via the new
usePaywallHost().observePresentation, whose handle'send()takes the
paywall a purchase was made on, so a purchase after a background
revalidation swapped the moment's variant reports that variant's store id, on
both surfaces. The types are copied verbatim from
rocalytics-sdk'ssrc/paywall/observer.ts, whose tracker satisfies them
structurally. Every observer call is guarded: a throw, a rejection, or a
start()returning no handle never changes whatpresent()resolves. -
A purchase wins over a paywall that disappears mid-presentation (#286).
Behaviour change forpresent(), not only the observer: if a purchase
settledpurchasedbefore the moment left the catalog (a studio publish, or
a query-key change such as a host setting a user property on purchase),
present()now resolvespurchased, noterror/paywall-disappeared. A
purchase still in flight is waited for, bounded at 180 s (the expo-iap
adapter's purchase wait), thendismissed; the Modal closes and other
present()calls work meanwhile. With no purchase, it still resolves
paywall-disappearedat once. -
A settled purchase sticks (#286). Once a purchase settles
purchased
during a presentation,present()resolvespurchasedand the observer ends
purchasedwith that purchase's key and transaction, whatever follows: a
later cancelled or second purchase, a dismissal, the paywall disappearing, or
a host-reported error such asrender-error. Before, the last store result
won and only a bare dismissal was upgraded, soregister()could deny a user
who had paid. -
Closing a
present()paywall waits for a purchase in flight (#286). A
dismissedclose (thedismissaction, Android back, a host close button)
while a purchase is pending frees the surface at once but resolves
present()only when the purchase settles, with its outcome, bounded at
180 s thendismissed. Other reported ends still resolve at once. -
usePaywallHost().completeis bound to one presentation (#286). Its
identity changes once per presentation, and a stale copy is a no-op: a
paywall's action chain that finishes after its presentation ended (an
onSuccess: [dismiss]resuming once the purchase settles) no longer closes
the paywall that replaced it. A host rendering its own modal must call the
completefrom the current render; one stashed across presentations now
does nothing, and the paywall it should have closed stays up. -
A paywall's
purchaseis bound to its presentation too (#286). The
product runtimePaywallProviderpublishes bindspurchaseto the
presentation on screen when it was rendered. A purchase requested through a
presentation that has since left (a stale action chain, e.g. acustomor
requestPermissionstep followed bypurchase, resuming after the paywall
closed) does not open the store and resolves{ status: "cancelled" },
recording nothing on the presentation now showing. Logged with a warning. -
ProductRuntime.presentationEnded?: () => boolean(#286). Additive and
optional:trueonce the paywall presentation a runtime was published for
has left the screen, the same test the boundpurchaserefuses on. The UI
package's action engine checks it before apurchaseand ends a stale
action chain there, so the refusal'scancellednever runsonCancel(or
any other branch, or any action after it). Absent on every other runtime,
which means "never ended". -
Only the host acknowledges a presentation (#286). The provider never
callsshown()or acknowledges on its own. A host's contract, in the
usePaywallHost().acknowledgePresentationJSDoc: acknowledge from the
Modal'sonShow; acknowledge again when a new presentation takes a Modal
kept mounted andvisibleacross the swap (anawait present()
continuation, or the same moment again, in the same commit), because
onShowdoes not fire twice, but only if anonShowarrived during the
Modal's current visible stretch; ignore anonShowthat arrives while the
Modal is hidden (iOS delivers one late for a paywall that ended before its
slide-in finished); and never acknowledge a presentation the host will not
render. A host of its
own that wiresonShowalone keeps working, but a same-commit successor is
abandoned afterpresentAckTimeoutMsashost-never-presented. A Modal
re-keyed per presentation firesonShoweach time and needs nothing more. -
A close or disappearance with a purchase in flight no longer waits when a
purchase has already settledpurchased: the end is fixed, so it ends
purchasedat once. -
requestPermissionButtonAction — ask the OS for a permission and branch
on the answer within the SAME press (#196). An eighthButtonActionmember:
{ type: "requestPermission", kind, onGranted?, onDenied?, onUnavailable? },
with all three hooks ordinary nestedButtonAction[]recursed through
runActionsexactly likepurchase.onSuccess.What was missing was the divergence, not the asking:
[{ type: "custom" }, "continue"]already ran host code and advanced, but the
custom handler's return value is discarded and there is no conditional action,
so a grant and a refusal could only lead somewhere different on a LATER
screen, through a variable the handler happened to write.kindis closed and deliberately narrow —notifications,
appTrackingTransparency,locationWhenInUse,camera,microphone,
photoLibrary. Each is asked through an optional Expo peer dep the app
installs itself; nothing native is bundled. HealthKit and Screen Time /
Family Controls are NOT members: both need app-owned entitlements and a config
plugin a library cannot ship, rather than pretending to work. The host
requestPermissionresolver does not admit them either —kindis closed, so
such a payload failsinvalid_unionbefore any resolver runs; adding them
starts with adding the kind here. The resolver overrides the six that exist.Reading a permission's current status is NOT part of this — there is still no
way to skip a screen because the permission was already granted.New exports:
PERMISSION_KINDS,PermissionKindSchema,PermissionKind,
RequestPermissionButtonAction,RequestPermissionButtonActionSchema,
actionsCanComplete— the same "can the user still get off this screen?" walk
ashasCompletingAction, over ONE action list rather than an element tree, so
the UI runtime can consult this package's definition instead of re-deriving it
when a permission it could not ask for would otherwise leave a screen with no
CTA — andcompletingActionKind(+ theEscapeActiontype), which answers
WHICH of the two completing actions that list reaches. The runtime needs the
kind, not the boolean:complete()andcomplete({status:"dismissed"})are
not interchangeable at aPaywallstep's hard gate, so standing in for an
authored{dismiss}with a bare advance handed out gated content (review
round 2 of #196). One walk answers both questions, and a test asserts they
never disagree.The escape-CTA guard reads this action with AND, not OR.
hasCompletingAction(the #209 "can the user still get off this screen?"
predicate) finds branch lists by shape, which forrequestPermissionwas
wrong: a CTA holding its only"continue"inonGrantedread as a way
forward, so the strip withheld its escape button from exactly the screen that
needs one — a refusal, or a build that never installed the optional module,
runs nothing and the user is stuck with no signal. It now counts the action
only when a grant AND a non-grant both reach"continue"/{dismiss},
mirroring the runtime's own three paths (a declaredonUnavailablewins; an
absent one is rescued by the runtime itself, so its absence is not a trap).
purchase/restorekeep the OR reading deliberately: a cancelled purchase
can be retried, a standing OS denial cannot. Found in review round 1 of #196.A misspelled outcome hook is now reported.
collectUnknownElementKeys
walksprops.actionsandprops.onPressas well as element nodes, deriving
each action's key set fromButtonActionSchemaitself, and reports unknown
keys withscope: "action"and the action'stype. The schema stays
non-strict for the reason it always was (rejecting unknown keys would take
down published payloads to report a no-op), but
{ type: "requestPermission", onGranted: [...], onDeneid: [...] }previously
validated clean with a dead denial branch — on a permission screen, a CTA the
refusing user cannot get past. It surfaces through the__DEV__check
OnboardingProvideralready runs on every fetched payload, and covers
purchase.onSuccesand any hook added later for free.Forward compatibility:
ButtonActionSchemais a plainz.union, and
#209's strip is keyed to unknown element types only. An app whose installed
SDK predates this action failsinvalid_unionon it, and the whole
ComposableScreen fails to parse — the element strip does not rescue an action
variant. Publishing one is a capability-floor decision (#233 / studio #313),
not something this release makes safe on its own — and a floor is only half
the answer, since it cannot help a build already in the field. Runtime
tolerance for an unknown ACTION type, the missing sibling of #209, is now
tracked as #262 (filed in review round 2 of #196, which found that nothing
tracked it). No floor version is quoted
in the docs or the LLM skills on purpose: which release carries this is
decided when it is cut, and understating a floor is the direction that costs
an audience its screens. The check that cannot go stale is the app's own
package —grep -o requestPermission node_modules/@rocapine/react-native-onboarding/dist/steps/common.types.js.
Fixed
-
expoIapProductProvidercould not complete a purchase against expo-iap
5.x, and never finished the ones it did take. Both halves were verified
against the installedexpo-iap@5.3.2before anything was changed.The request went out as
request: { ios, android }.normalizeRequestProps
(build/index.js:650-656) readsrequest.apple/request.google, so it
resolvedundefinedand expo-iap threwEmptySkuListbefore the store was
ever asked — the adapter'scatchturned that into{ status: "error" }, so
a user tapping Buy could only ever reachonError. The comment claiming 5.x
wanted{ ios, android }was true of early 5.0/5.1 and stopped being true
inside the 5.x line. One shape is now sent for every supported peer, because
4.x readsapple/googlefirst too and only warns when it falls back
(4.7.2build/index.js:666-679) — and thefetchProducts-presence probe that
was meant to separate the generations never could, since 4.4+ ships it.Nothing subscribed to
purchaseUpdatedListener.requestPurchaseresolves
nullon the normal path and delivers the transaction as an event ("the
result is delivered throughpurchaseUpdatedListener— NOT the return
value", expo-iap's own docstring), sofinishTransactiononly ran on the
path expo-iap documents as abnormal. An unfinished transaction is re-delivered
by StoreKit on every launch, and Play auto-refunds an unacknowledged
purchase after 3 days — money taken and silently given back. -
Answering the caller and acknowledging the money are now separate. The
first attempt at the above conflated them: oneclaimedflag guarded both,
and both listeners were removed as soon as the caller had an answer. Every
path that answered early therefore lost the ability to finish the charge — an
unrelated product's error, apendingupdate, the wait elapsing. The
provider now opens its listeners once, on first connect, and keeps them
for its lifetime; a caller's answer slot closes independently. Four
money-losing consequences, all now covered by tests:- A
pendingpurchase that clears is finished. Play emits
purchaseState: "pending"and then"purchased"for the same transaction.
Not finishing the pending one is correct — Android has nopurchaseToken
yet (build/index.js:865-874) — but treating it as terminal meant the
cleared purchase was never acknowledged, and Play refunded it. - A verdict that arrives after the wait elapses is finished. Ask to Buy,
an SCA step-up, or adding a card inside the sheet routinely exceed the
3-minute default. - Another product's failure no longer answers this purchase.
purchaseErrorListeneris process-wide and expo-iap forwards the event
verbatim (build/index.js:195-200); on Android the module emits from a
single per-module listener and flushes events buffered while disconnected on
the next successfulinitConnection(ExpoIapModule.kt:198-205). Failures
are now attributed byproductId/productIds, and an error naming no
product is taken only when a single purchase is in flight. - An acknowledgement that throws is retried on the next store round-trip
instead of being logged and forgotten, and no transaction is ever finished
twice.
Keeping one long-lived subscription is also forced by expo-iap's iOS dedupe:
each newpurchaseUpdatedListenerseeds its history from a process-wide
set of transaction ids (ids: new Set(purchaseUpdatedDedupeHistoryIOS.ids),
build/index.js:148-151). Subscribing per Buy tap inherits every id any other
listener in the process has seen — the host's ownuseIAP, for instance — and
silently drops those deliveries. - A
-
requestPurchaseis no longer retried on a lost connection. An earlier
version of this entry claimed the retry "cannot double-charge" because the
relevant codes are thrown before any billing flow starts. That claim was
wrong and is withdrawn:requestPurchasesetsreachedOpenIapRequest = truebefore callingopenIap.requestPurchase
(ExpoIapModule.kt:435-436),deliverPurchaseRequestFailure(:63-75)
rejects the pending promise on every path including mid-flight, and
service-disconnectedis the catch-all code for any failure that is not
anOpenIapError(:60-61) — so it carries no information about whether the
store sheet was ever shown. The retry could present a second sheet, charge a
consumable twice and discard the result. Reads still retry; the purchase path
does not. -
A dismissed store sheet is
"cancelled"again, not an error. 5.x
normalized every code to openiap kebab-case (ErrorCode.UserCancelled === "user-cancelled") and carries nouserCancelledboolean; only the pre-5.x
screaming-snake codes were tested. All shapes are now accepted. -
Play products resolve at all, and to the right base plan. Refs are
authoredproductId:basePlanId, butfetchProductsfilters the store's
answer by the bareitem.id, so the composite id matched nothing and the
product was dropped with no warning — a blank Android paywall reporting
status: "ready". The id is now split: the product half queries the store,
the base plan half selects the offer, and itsofferTokenAndroidis sent as
subscriptionOffers: [{ sku, offerToken }], without which Play cannot select
a base plan. -
An Android billing period no longer comes from the free trial.
pricingPhaseList[0]is the trial or intro phase whenever one exists, so a
$59.99/year plan with a one-week trial reportedperiodIso: "P1W"— and,
sincederiveProductFieldsdivides the price by the period it is given, a
pricePerYeararound $3,130. The infinite-recurring phase is used instead. -
restore()syncs before it reads, returns product ids, and only counts
purchases that were paid for. It calledgetAvailablePurchases()bare;
restorePurchases()performs the iOS StoreKit sync first and then refreshes
(build/index.js:886-899), which behind a user-facing Restore button is the
point of pressing it.Purchase.idis the transaction id and is always
present, sop.id ?? p.productIdnever fell through and the host was handed
transaction ids to match against its entitlements. AndgetAvailablePurchases
reports unfinished purchases, so an Android slow-payment purchase entitled the
user before the money moved; only an explicitpurchaseState: "purchased"now
counts. -
A failed product query is an error, not an empty catalog.
FetchProductsResultincludesnull, which was coerced to[]— putting
the runtime instatus: "ready"with every{{product.*}}blank and nothing
said about why. A ref the store returns nothing for is now named in a
console.warn. -
The store connection recovers.
endConnectionis process-wide, so any
otheruseIAPunmounting — or an AndroidServiceDisconnected— closed the
connection this adapter opened, and the cached resolved connect promise then
made every later call fail for the life of the process. A connection-lost
code now reopens the connection and retries the read once. -
An iOS consumable is consumed rather than acknowledged, so it can be
re-bought. Only iOS publishes the discriminator (typeIOS); Android
one-time products are alltype: "in-app"and consumability is the app's
own decision, so a Play consumable still needs the host to say so. -
A transaction returned inline is checked against the sku like a delivered
one. iOS sometimes resolves the transaction fromrequestPurchaseitself;
that path skipped the product check the listener path applies, so a replayed
transaction handed back inline could answer the wrong purchase. -
A second
getProductsno longer wipes the first. The per-product store
metadata was cleared on every resolve, so already-rendered products lost the
offer token and type discriminator needed to buy them. It is also keyed per
ref now, not per product id — two refs routinely name one Play subscription
through different base plans. -
A subscription with a compound period is bought as a subscription.
toPeriodrejectsP1Y1M, so aperiod-based discriminator labelled a real
subscriptionin-appand Play rejects the wrong type outright.periodIso
decides now. -
A store error keeps the store's own message.
purchaseErrorListener
delivers the plain{ code, message }PurchaseErrorfromtypes.d.ts, not
theErrorsubclassrequestPurchaserejects with, so
new Error(String(e))handed the host"[object Object]"at the one point it
would read a diagnosis. The code is preserved on theErrortoo. -
The plugin's paywall docs no longer scope
"pending"to Stripe.
setup-paywalls,compose-screen-builder,validate-step-jsonand the
step-json-revieweragent all said a"pending"result was a Stripe-only
outcome, so an integrator could compose an expo-iap paywall with no
onPendingand have both the validator and the reviewer stay silent — the
frozen-paywall failure. They now name which providers can produce it:
stripeLinkProductProvideralways,expoIapProductProviderfor a purchase
awaiting payment, andrevenueCatProductProvider/stubProductProvider
never. -
The test fake no longer ships in the published package.
packages/onboarding/tsconfig.jsonexcluded**/*.test.tsbut nothing that
matched__tests__/expoIap5Fake.ts, sobuild:headlessemitted it into
dist/products/adapters/__tests__/andfiles: ["dist", "src"]put it in the
tarball. The exclude list now covers**/__tests__/**.
Added
-
expoIapProductProvider(Iap?, options?), with the option type exported
from the package root (ExpoIapProviderOptions,ExpoIapProvider) the way
StripeLinkProviderConfigalready was:onUnclaimedPurchase(purchase) => boolean | Promise<boolean>— closes
#257.
StoreKit re-delivers an unfinished transaction on every launch, and a
purchase can also arrive from an Ask to Buy approval, a promoted product, or
a pending Play purchase that cleared while the app was closed. None of those
has a caller waiting, and finishing one blind would take the money while
granting nothing — destroying the very replay that lets the app recover. So
the host decides: returntrueonce entitlement is granted and the
transaction is finished; leave it unset and it is left alone, re-delivered
next launch, with oneconsole.warnnaming the product. A purchase this
provider dispatched in this process never goes here.purchaseTimeoutMs— how long the CALLER waits before being told
"pending"(default 3 minutes). It no longer ends the transaction: the
listeners stay attached and a later verdict is still acknowledged.dispose()on the returned provider, additive toProductProvider. A
provider now holds a store subscription for its lifetime, so a host that
rebuilds one per render should tear the old one down.
Known gaps
- A daily plan still reports
period: "week". All three adapters share the
mapping andProductPeriodhas no"day"member, so this is a shared-type
change rather than part of this fix — tracked in
#258.
periodIsois exact, so every derived per-period price is already correct. - Android consumables cannot be detected, only declared — see above.
- The iOS dedupe still bounds what any adapter can hear. A transaction id
already recorded process-wide before this provider's listener is attached is
invisible to it, and the global history is cleared only by a successful
endConnection. Subscribing once, on first connect, makes that window as
small as this adapter can make it; nothing here can shrink it further.