Skip to content

v1.76.0

Choose a tag to compare

@github-actions github-actions released this 12 Oct 12:59
· 13 commits to main since this release
0a47af0

Added

  • A custom paywall screen can pass its purchase transaction to
    complete()
    (#287). CustomPaywallScreenProps.complete takes 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,
    and restored: true for a purchase that only restored access. Both
    surfaces, the PaywallHost Modal and the inline Paywall step, forward it to
    the observer's end(), so a purchase the SDK never saw is attributed to its
    presentation. Sanitized first: an id that is not a non-empty string, a
    restored that is not exactly true, and every other field are dropped, and
    nothing is sent when nothing survives; a transaction that throws when read (a
    throwing getter, a revoked Proxy) is dropped and never stops the end, so
    the observer still gets it once and present() still resolves. Sent only
    when the screen reports status: "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
    productId is present and equals the recorded slot's store id, restored is
    never added, and in every other case (no productId, a different one, or a
    recorded slot with no store id on this platform) the screen's ids are dropped
    with a warning. A stale complete from an ended presentation stays a no-op,
    transaction included.
    usePaywallHost().complete takes the transaction as an optional second
    argument. PresentResult is unchanged: present() never resolves with it.
    Omitting it behaves exactly as before.

  • PaywallProvider observer prop — an optional PaywallObserver told
    about every paywall presentation, so a host can send it to its own analytics
    (#286). start(info) runs once present() accepts (never for an
    unknown-moment or already-presenting refusal), handle.shown() on the
    host's acknowledgement, and handle.end(outcome) exactly once with the
    reconciled result — every later error as status: "error" with its reason.
    An elements-mode purchase carries transaction.productId, the purchased
    slot's id for the running platform. The inline Paywall step reports through
    the same observer with surface: "paywall_step", via the new
    usePaywallHost().observePresentation, whose handle's end() 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's src/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 what present() resolves.

  • A purchase wins over a paywall that disappears mid-presentation (#286).
    Behaviour change for present(), not only the observer: if a purchase
    settled purchased before 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 resolves purchased, not error / paywall-disappeared. A
    purchase still in flight is waited for, bounded at 180 s (the expo-iap
    adapter's purchase wait), then dismissed; the Modal closes and other
    present() calls work meanwhile. With no purchase, it still resolves
    paywall-disappeared at once.

  • A settled purchase sticks (#286). Once a purchase settles purchased
    during a presentation, present() resolves purchased and the observer ends
    purchased with 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 as render-error. Before, the last store result
    won and only a bare dismissal was upgraded, so register() could deny a user
    who had paid.

  • Closing a present() paywall waits for a purchase in flight (#286). A
    dismissed close (the dismiss action, 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 then dismissed. Other reported ends still resolve at once.

  • usePaywallHost().complete is 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
    complete from the current render; one stashed across presentations now
    does nothing, and the paywall it should have closed stays up.

  • A paywall's purchase is bound to its presentation too (#286). The
    product runtime PaywallProvider publishes binds purchase to the
    presentation on screen when it was rendered. A purchase requested through a
    presentation that has since left (a stale action chain, e.g. a custom or
    requestPermission step followed by purchase, 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: true once the paywall presentation a runtime was published for
    has left the screen, the same test the bound purchase refuses on. The UI
    package's action engine checks it before a purchase and ends a stale
    action chain there, so the refusal's cancelled never runs onCancel (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
    calls shown() or acknowledges on its own. A host's contract, in the
    usePaywallHost().acknowledgePresentation JSDoc: acknowledge from the
    Modal's onShow; acknowledge again when a new presentation takes a Modal
    kept mounted and visible across the swap (an await present()
    continuation, or the same moment again, in the same commit), because
    onShow does not fire twice, but only if an onShow arrived during the
    Modal's current visible stretch; ignore an onShow that 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 wires onShow alone keeps working, but a same-commit successor is
    abandoned after presentAckTimeoutMs as host-never-presented. A Modal
    re-keyed per presentation fires onShow each time and needs nothing more.

  • A close or disappearance with a purchase in flight no longer waits when a
    purchase has already settled purchased: the end is fixed, so it ends
    purchased at once.

  • requestPermission ButtonAction — ask the OS for a permission and branch
    on the answer within the SAME press (#196). An eighth ButtonAction member:
    { type: "requestPermission", kind, onGranted?, onDenied?, onUnavailable? },
    with all three hooks ordinary nested ButtonAction[] recursed through
    runActions exactly like purchase.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.

    kind is 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
    requestPermission resolver does not admit them either — kind is closed, so
    such a payload fails invalid_union before 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
    as hasCompletingAction, 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 — and completingActionKind (+ the EscapeAction type), which answers
    WHICH of the two completing actions that list reaches. The runtime needs the
    kind, not the boolean: complete() and complete({status:"dismissed"}) are
    not interchangeable at a Paywall step'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 for requestPermission was
    wrong: a CTA holding its only "continue" in onGranted read 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 declared onUnavailable wins; an
    absent one is rescued by the runtime itself, so its absence is not a trap).
    purchase / restore keep 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
    walks props.actions and props.onPress as well as element nodes, deriving
    each action's key set from ButtonActionSchema itself, and reports unknown
    keys with scope: "action" and the action's type. 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
    OnboardingProvider already runs on every fetched payload, and covers
    purchase.onSucces and any hook added later for free.

    Forward compatibility: ButtonActionSchema is a plain z.union, and
    #209's strip is keyed to unknown element types only. An app whose installed
    SDK predates this action fails invalid_union on 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

  • expoIapProductProvider could not complete a purchase against expo-iap
    5.x, and never finished the ones it did take.
    Both halves were verified
    against the installed expo-iap@5.3.2 before anything was changed.

    The request went out as request: { ios, android }. normalizeRequestProps
    (build/index.js:650-656) reads request.apple / request.google, so it
    resolved undefined and expo-iap threw EmptySkuList before the store was
    ever asked — the adapter's catch turned that into { status: "error" }, so
    a user tapping Buy could only ever reach onError. 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 reads apple/google first too and only warns when it falls back
    (4.7.2 build/index.js:666-679) — and the fetchProducts-presence probe that
    was meant to separate the generations never could, since 4.4+ ships it.

    Nothing subscribed to purchaseUpdatedListener. requestPurchase resolves
    null on the normal path and delivers the transaction as an event ("the
    result is delivered through purchaseUpdatedListener — NOT the return
    value", expo-iap's own docstring), so finishTransaction only 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: one claimed flag 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, a pending update, 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 pending purchase that clears is finished. Play emits
      purchaseState: "pending" and then "purchased" for the same transaction.
      Not finishing the pending one is correct — Android has no purchaseToken
      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.
      purchaseErrorListener is 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 successful initConnection (ExpoIapModule.kt:198-205). Failures
      are now attributed by productId/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 new purchaseUpdatedListener seeds 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 own useIAP, for instance — and
    silently drops those deliveries.

  • requestPurchase is 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: requestPurchase sets reachedOpenIapRequest = true before calling openIap.requestPurchase
    (ExpoIapModule.kt:435-436), deliverPurchaseRequestFailure (:63-75)
    rejects the pending promise on every path including mid-flight, and
    service-disconnected is the catch-all code for any failure that is not
    an OpenIapError (: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 no userCancelled boolean; 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
    authored productId:basePlanId, but fetchProducts filters the store's
    answer by the bare item.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 its offerTokenAndroid is 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 reported periodIso: "P1W" — and,
    since deriveProductFields divides the price by the period it is given, a
    pricePerYear around $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 called getAvailablePurchases() 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.id is the transaction id and is always
    present, so p.id ?? p.productId never fell through and the host was handed
    transaction ids to match against its entitlements. And getAvailablePurchases
    reports unfinished purchases, so an Android slow-payment purchase entitled the
    user before the money moved; only an explicit purchaseState: "purchased" now
    counts.

  • A failed product query is an error, not an empty catalog.
    FetchProductsResult includes null, which was coerced to [] — putting
    the runtime in status: "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. endConnection is process-wide, so any
    other useIAP unmounting — or an Android ServiceDisconnected — 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 all type: "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 from requestPurchase itself;
    that path skipped the product check the listener path applies, so a replayed
    transaction handed back inline could answer the wrong purchase.

  • A second getProducts no 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.
    toPeriod rejects P1Y1M, so a period-based discriminator labelled a real
    subscription in-app and Play rejects the wrong type outright. periodIso
    decides now.

  • A store error keeps the store's own message. purchaseErrorListener
    delivers the plain { code, message } PurchaseError from types.d.ts, not
    the Error subclass requestPurchase rejects 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 the Error too.

  • The plugin's paywall docs no longer scope "pending" to Stripe.
    setup-paywalls, compose-screen-builder, validate-step-json and the
    step-json-reviewer agent all said a "pending" result was a Stripe-only
    outcome, so an integrator could compose an expo-iap paywall with no
    onPending and have both the validator and the reviewer stay silent — the
    frozen-paywall failure. They now name which providers can produce it:
    stripeLinkProductProvider always, expoIapProductProvider for a purchase
    awaiting payment, and revenueCatProductProvider/stubProductProvider
    never.

  • The test fake no longer ships in the published package.
    packages/onboarding/tsconfig.json excluded **/*.test.ts but nothing that
    matched __tests__/expoIap5Fake.ts, so build:headless emitted it into
    dist/products/adapters/__tests__/ and files: ["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
    StripeLinkProviderConfig already 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: return true once entitlement is granted and the
      transaction is finished; leave it unset and it is left alone, re-delivered
      next launch, with one console.warn naming 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 to ProductProvider. 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 and ProductPeriod has no "day" member, so this is a shared-type
    change rather than part of this fix — tracked in
    #258.
    periodIso is 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.