@rocapine/react-native-onboarding
Changelog
All notable changes to @rocapine/react-native-onboarding are documented here.
[Unreleased]
Added
-
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 throughrunActionsexactly 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 hostrequestPermissionresolver 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.
collectUnknownElementKeyswalksprops.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__checkOnboardingProvideralready runs on every fetched payload, and coverspurchase.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.requestPurchaseresolvesnullon 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: one
claimedflag 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 emitspurchaseState: "pending"and then"purchased"for the same transaction. Not finishing the pending one is correct — Android has nopurchaseTokenyet (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 new
purchaseUpdatedListenerseeds 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, andservice-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 authored
productId: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 reportingstatus: "ready". The id is now split: the product half queries the store, the base plan half selects the offer, and itsofferTokenAndroidis sent assubscriptionOffers: [{ 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, apricePerYeararound $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. AndgetAvailablePurchasesreports 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 aconsole.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 from
requestPurchaseitself; 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.periodIsodecides now. -
A store error keeps the store's own message.
purchaseErrorListenerdelivers the plain{ code, message }PurchaseErrorfromtypes.d.ts, not theErrorsubclassrequestPurchaserejects with, sonew 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 thestep-json-revieweragent all said a"pending"result was a Stripe-only outcome, so an integrator could compose an expo-iap paywall with noonPendingand 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/stubProductProvidernever. -
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 intodist/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 wayStripeLinkProviderConfigalready 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.
[1.75.0] - 2026-09-07
Fixed
-
in/not_inno longer answer a constant when the right-hand side is not literally an array.evaluateLeafgated both operators onArray.isArray(value)and, without one, returnedfalseforinandtruefornot_in— for every row, with no validation error and no warning. The two shapes that hit that path are exactly the two an author writes now that a{{ref}}on the right-hand side resolves:value: "{{selected}}", where the multi-select variable's flat value is the JSON-encoded string aCheckboxGroupwrites ('["sleep","energy"]'), andvalue: ["{{selected}}"]— the shape Studio's condition editor emits, because it splits its value field on commas, so the reference lands as the single member of a one-member array. The first was not an array at all; the second was an array of one JSON string, which matched no row either. A membership gate over a multi-select therefore either hid every row or showed every row.The right-hand side is now normalized to a member list: the value and each of its members are decoded (one level of flattening), so all three shapes — an authored array, a bare reference, and an array-wrapped reference — mean the same list. Members compare stringified, because a decoded JSON array keeps its members typed and
Repeatdeliberately keeps a numeric row field numeric, sonot_in(1, [1, 2])used to betrue.containsagainst an array variable shares that comparison, so the two operators can no longer disagree about the same data.Unchanged: an empty list (
value: [], what an empty Studio value field yields) and a reference to a variable nobody has written both have no members, soinmatches nothing andnot_inmatches everything — as documented. That holds in either shape: an unresolved reference interpolates to the empty string, and an empty-string member is dropped rather than kept as a member that is empty, sovalue: ["{{unset}}"]cannot disagree withvalue: "{{unset}}"— which matters because an empty-string variable is reachable (InputElementwrites""on clear,Input.defaultValue: ""). A right-hand side that is no list at all (operator: "in", value: "male") now reads as a one-member list and logs aconsole.warnnaming the operator, rather than silently answering a constant; nothing Studio can author produces that shape. Everything routing through the shared evaluator inherits the fix: elementrenderWhen,Button disabledWhen, and step branching inresolveNextStepNumber. Refs #225; unblocks the "available"/"not yet tagged" buckets of a grouped or dual-list multi-select. -
A condition can now compare a variable against another variable.
evaluateLeafcomparedcondition.valueverbatim, so a condition's right-hand side could only ever be an authored literal — yetscreens/elements/RepeatElement.tshas always documented{ variable: "item.sign", operator: "eq", value: "{{zodiacSign}}" }as the way to makeRepeatbehave as a switch, and cites it as the reason there is no separateMatchelement. That comparison never matched. It tested the row's sign against the 14-character string"{{zodiacSign}}"and failed silently — no validation error, no warning, just a repeated subtree in which every row was filtered out. A documented contract that did not work.The rule now is:
{{name}}on a condition's right-hand side is resolved against the variable map before the comparison, including inside anin/not_inarray. A reference resolves to the variable's value, not its display label — the same convention asImage mode:"expression"— so a gate compares machine identifiers rather than translated copy. An unknown reference resolves to the empty string rather than throwing, so a gate on a variable nobody has written yet simply does not match, exactly as an unsatisfied literal comparison would.Because this lands in the shared evaluator, everything that routes through it inherits it with no other change: element
renderWhen, and step-branch routing inresolveNextStepNumber— a branch may now compare two answers instead of a stored answer against a constant. Hosts calling the exportedevaluateCondition/evaluateLeafget the same behaviour. Refs #217; see the UI package's CHANGELOG for the matching fix to the UI-thread gate, which bypasses this function. -
A screen no longer fails because it contains an element type the installed app does not know.
UIElementSchemais az.discriminatedUnion("type", …)over the element types a build knows, so a type published after an app shipped missed every branch and failed the wholeelementsarray — and the ComposableScreen renderer parsed with a throwing.parse. Publishing one new element type therefore took down the entire screen on every already-installed app, not just the element it could not draw: the error fallback has no interactive control, and the back chevron lives in<ProgressBar>behind the step'sdisplayProgressHeader, so on a header-off step there was no exit in either direction —onContinuehad died with the subtree.The rule now is: an element type this build cannot render is omitted with its subtree in front of the parse, reported, and the rest of the screen renders. Publishing a screen that uses a new element type is safe for older apps, which it was not before.
Loosening the union was deliberately not the fix. A catch-all branch would have swallowed real data bugs as well, so everything that is not an unknown type still parses strictly: a
variantoutside its enum or a missingidkeeps failing loudly with its exact path.ScreenElementsSchemaitself stays strict, so authoring- and publish-time validation still reports a typo'd element type as an error rather than quietly dropping it. Omit is the contract the runtime already implements at its other boundaries —renderElement's terminalreturn null,buildAnimation's unknown-preset no-op,OnboardingPage's unknown-step skip.A strip can take the screen's only way forward, so the same call now answers for that too. A ComposableScreen authors its CTA inside the element tree, so dropping an unknown root container leaves
elements: [], which parses cleanly: the loud throw this replaced would have become a silent screen with nothing to press.resolveRenderableStepreturnsneedsEscapewhen nothing that survived can complete the step —hasCompletingActionwalks the surviving tree for a press-reachable"continue"or{type:"dismiss"}, followingrunActions, the only thing in the runtime that callsonContinue— and the renderer supplies its own button. Only ever after a strip: an authored screen with no CTA is the author's business and does not acquire an SDK button.What this does not cover: no
sdkVersionreaches the backend, so Studio cannot compute a capability floor or gate a publish on it. An element type published to an audience running older builds is a partial screen plus a warning in the host's logs — not an error anyone is shown before it ships.
Added
-
setVariable valueMode: "expression"now documents a function stdlib, and the shipped example payload demonstrates it. Nothing in this package's schema changed —valueis still a plain string and the grammar is not schema-encoded — butSetVariableButtonAction's JSDoc is where an author reads what an expression may contain, and theonboarding-example.tsComposableScreen step now computes a goal date, a grammatical goal sentence and a weekly pace from one press. The evaluator itself lives in the UI package (Runtime/elements/expression.ts); the two are joined by a peer-dependency range, so on a UI build older than that the call cannot tokenize and the template falls back to plain interpolation, storing the literal source text. -
resolveRenderableStepis public API — the whole render-boundary decision in one pure call: what to parse, what to report, and whether the renderer must supply its own way off the screen.deriveElementTypeNames(schema)is exported with it, because a rendering package must key the strip on its own element union rather than this one's. The two packages are peers joined by a peer-dependency range, so their installed versions can legitimately differ; keying on the headless schema could strip an element the installed UI draws perfectly well, or keep one it cannot draw and throw the screen anyway. Mechanism shared, answer per package.Also exported:
KNOWN_ELEMENT_TYPES(this build's capability list, which is what a publish-time gate would need),dropUnknownElementTypes/dropUnknownElementTypesInStep,collectUnknownElementTypes/collectUnknownElementTypesInSteps,formatUnknownElementTypes, andhasCompletingAction.
[1.74.1] - 2026-09-02
Fixed
-
A user-property write during an onboarding no longer blanks the app.
OnboardingDataGatefollowed the user-property store reactively: asetUserPropertymid-flow changed the merged audience params, the React Query key followed them, the query answereddata: undefinedfor the never-seen key, and the gate renderednull— unmounting the entire subtree under the provider (in hosts that wrap the app: router reset, every screen's state lost), refetchingget-onboarding-steps, and remounting. The only workaround was to seed every property before the provider mounted.The rule now is: audience resolution happens at serve time, and a served payload is frozen for that presentation.
OnboardingProviderresolves the effective params once, from the first ready snapshot of the store, and pins them for the lifetime of the mount (useAudienceParams); the data gate just fetches what it is handed. A property written during the flow — or a change to thecustomAudienceParamsprop — does not re-key, refetch or swap the onboarding; it applies to the next serve (next mount, next launch). Hosts can write a property the moment they compute it, even mid-onboarding. The corollary: anything the current serve must target on has to be set before the provider mounts.reset()likewise clears for the next serve.PaywallProvideris deliberately unchanged: a paywall is served atregister(moment), so it is right that its catalog follows the store until then, and it never blanks while refetching.The escape hatch is intact:
client.clearCache()plus invalidating["onboardingQuestions", …]still refetches — the same query, under the pinned audience, without an unmount. Re-targeting with the current properties is a new serve: remount the provider — at a flow boundary, since a remount is the full teardown. -
useOnboardingStep/useOnboardingStartnow build the query the gate served. They built their ownuseSuspenseQueryfrom the rawcustomAudienceParamsprop, while the gate (since 1.74.0) merged the store over it — so with a non-empty store the two keys differed: a second fetch, resolved without the user's properties, and that was the payload the screens rendered.OnboardingProvidernow resolves the params once and hands that same value to both the data gate and theOnboardingProgressContextthe hooks already read, so both build the same query and there is exactly one fetch. The hooks themselves are unchanged.
Changed
- Tests — the headless package can now render React in tests (
react-dom+jsdomdev dependencies,*.test.tsxexcluded fromtsc). The provider suite renders the realOnboardingProvideragainst a fake client.
[1.74.0] - 2026-08-27
Added
-
OnboardingStudio— the SDK's front door, in the shape of the SDKs it sits alongside (Superwall.configure,Purchases.configure,amplitude.init): one module-level object owning configuration and user identity.OnboardingStudio.init({ projectId: "…", appVersion: "1.0.0" }); // returns the client
OnboardingStudio.setUserProperty("plan", "free");
OnboardingStudio.setUserProperties({ daysSinceInstall: 3 }); // merges
OnboardingStudio.setUserProperty("plan", null); // deletes
OnboardingStudio.removeUserProperty("plan");
OnboardingStudio.getUserProperties();
OnboardingStudio.reset(); // forget the user
OnboardingStudio.getClient() / isInitialized();
const { properties, status } = useUserProperties(); // React read pathinitis idempotent for an unchanged config — Fast Refresh re-runs module scope, and rebuilding the client there would orphan the one the providers already hold. A genuinely changed config replaces the client and warns.reset()clears user properties, in memory and on disk, and deliberately leaves the configuration and the payload cache alone: logging out should forget who someone is, not force a refetch of content that has not changed.getClient()?.clearCache()is there for both.User properties feed audience resolution for both onboardings and paywalls. Values are
string | number | boolean; they persist to AsyncStorage and are hydrated before the first fetch, so a returning user is targeted correctly on the first launch-frame with no host code. A first-ever install has nothing to hydrate — seed it withinit({ …, userProperties: { plan: "free" } }), which runs before anything renders, and even that launch is targeted correctly.register/presentdeliberately do not live on this object, unlike Superwall'sregister: presenting needs the mounted provider's catalog and presentation state, so they stay onusePaywall(), where a call cannot be made before a provider exists. -
clientis now optional on both providers. Omit it and they use the clientinit()built; pass one and it still wins, so every existing host is unaffected. With neither, the two providers behave differently on purpose:OnboardingProviderthrows (an onboarding with no client has nothing to render, and a hostErrorBoundarycatches one screen) whilePaywallProviderwarns and renders its children with paywalls inert — it wraps the whole app, so throwing would take down every screen over a missing paywall client.Eight names are refused with a warning —
projectId,platform,appVersion,draft,locale,omitNulls,moment,now. The last two are server-owned; the other six would break the request outright, because the client appends user params before its own,URLSearchParamspermits duplicates, and the two server-side readers disagree about which wins (.get()takes the first — the user's value — whileObject.fromEntriestakes the last). -
register(moment, feature)onusePaywall()— gate a feature on a moment. Runs the feature immediately when the moment has no paywall, otherwise presents it and runs the feature only on a purchase. Resolves{ ran, presented, reason, outcome? }.It gates on the moment alone — there is no entitlement check. Exclude existing subscribers with a user property plus an audience filter.
It fails open: with no reachable catalog it runs the feature and warns, because failing closed would make gated features silently dead on an offline launch.
reason: "catalog-unavailable"is how a host measures that rate.A Stripe-billed paywall never runs the feature even on a successful checkout — a Payment Link's entitlement arrives out-of-band through RevenueCat, so the presentation never reports
"purchased".registerwarns when it presents one. -
registerTimeoutMsonPaywallProvider(default3000) — how longregisterwaits for the catalog to settle before deciding without it. -
resolveRegisterDecision/shouldRunFeatureare exported: they are pure, so a host building its own gating oncatalogcan reuse the SDK's exact rules rather than reimplement them slightly differently.
Changed
- Both providers now merge the store over
customAudienceParams, store-wins per key, and hold their query until the store hydrates. The prop is neither deprecated nor removed — it becomes the static baseline (build-time facts) while the store carries what changes at runtime, so existing hosts are untouched. One consequence worth naming: one store now feeds both waterfalls, so an onboarding audience and a paywall audience can no longer disagree about the same user, which two independent props always allowed.
Fixed
-
The AsyncStorage cache keys are now scoped by audience params. The react-query key always was; the disk key was a bare constant, so a cache-first read could serve a payload resolved under different params — non-null, and so indistinguishable from a correct one. Observed in production: an audience gated on
hoursSinceOnboardingPaywall >= 44was served the pre-threshold catalog on the launch where the user first became eligible, so the arm under test lost exactly the launch that mattered. Rare while params were a static prop; mutable properties would have made it the normal path.An empty params hash yields the legacy key byte-for-byte, so existing installs keep their cache. This is also what makes
catalogStatus: "revalidating"trustworthy — a served catalog now always matches the current params — whichregister's decision relies on. -
clearCache()now clears every params variant, via agetAllKeys()prefix scan rather than naming two keys it can no longer predict. It previously missed every key but the current one. It deliberately does not clear user properties: clearing a payload cache must not forget who the user is.
[1.73.0] - 2026-08-27
Added
-
PaywallStepType— a step that IS a paywall.payloadis one field, amoments.key:{ "type": "Paywall", "payload": { "moment": "onboarding_end" } }Rendered inline, in flow position, by the UI package. The whole thing needed no wire change:
get-paywallsalready returns the catalog keyed by moment with the audience waterfall applied, so resolving the step is a lookup — and targeting plus weighted A/B therefore work inside an onboarding with no new machinery. Composable and custom-screen paywalls both work, becauserenderModeis a property of the paywall the moment resolved to, not of the step.Deliberately not a paywall id: that would bypass the waterfall, so A/B-testing a paywall inside an onboarding would mean duplicating the whole onboarding.
-
PaywallProvideracceptscustomScreens, andusePaywall()/usePaywallHost()expose it. This is now the canonical place to register custom paywall screens, because two things render them:PaywallHost's Modal and the inlinePaywallstep, which never goes throughPaywallHost.PaywallHost's owncustomScreensprop (1.72.0) still works and wins where passed, so existing integrations are unaffected — but it is invisible to aPaywallstep, so prefer the provider. -
CustomPaywallScreenProps/CustomPaywallScreensare now exported from this package. They moved here from-uibecause the registry they type is published onPaywallProvider. The UI package re-exports both names, so its deep-import path resolves unchanged. -
usePaywall().isProviderMounted— whether a realPaywallProvideris above the consumer. Needed by anything that renders a spinner while the catalog loads: with no provider,catalogStatusreports"loading"and nothing ever arrives, so such a consumer would spin forever. Deliberately NOT a newCatalogStatusmember — widening that union would break every host switching exhaustively over it, and "no provider" is not a catalog state.
[1.72.0] - 2026-08-27
Added
-
Paywall.renderMode,.customScreenIdand.customPayload— a paywall can now be a host-rendered custom screen instead of an authored element tree.renderMode: "custom"means the HOST draws it:customScreenIdnames a screen registered onPaywallHost(UI package), andcustomPayloadis a map of slot key to per-platform store product id ({ monthly: { ios, android } }) — the one thing a native paywall cannot get from anywhere else, since the moment waterfall picked this variant and which products it offers is an authoring decision.All three are optional, and an absent
renderModereads as"elements". Not because the studio omits them — it always sends a value — but because a device on this SDK can be talking to an olderget-paywallsthat predates the fields, and on that pairing every existing paywall must behave exactly as before.A property of the PAYWALL rather than the moment, the same as
billing: one moment audience can weight an element-tree variant against a native-screen variant and ramp the change as an A/B test. -
PaywallCustomPayload— exported so a host's custom screen can type the product map it receives without restating the shape. -
PresentErrorReason: "unknown-custom-screen"— arenderMode: "custom"paywall named a screen this host did not register (or named none at all), so nothing could be rendered and the Modal was never opened. Deliberately NOT folded into"parse-error": that one is a CMS data bug the studio author must fix, this is a wiring bug the app must fix. Hosts switching exhaustively onPresentErrorReasonmust widen that switch.
Changed
- No store products are resolved for a custom paywall.
collectProductRefsdoes not walkcustomPayload, so such a paywall issues no store round-trip at all and its screen receives product ids rather than prices. A native paywall asks the store for its own display prices, which is precisely what it does not need the studio for.
[1.71.0] - 2026-08-26
Fixed
-
expoIapProductProviderwas broken against expo-iap 5.x — every product silently failed to resolve. Five separate API mismatches, none of which any test exercised (the only existing coverage asserted that the adapter fails politely when expo-iap is absent):getProducts(skus)no longer exists in expo-iap 5.x; it isfetchProducts({ skus, type }), an object argument. The old call threwM.getProducts is not a function. The legacy name is still used as a fallback so a host pinned to expo-iap ≤4 keeps working.initConnection()was never called. Nothing opens the store connection implicitly —useIAPdoes it for hook consumers, but an adapter is not a hook — so every query failed. Now opened once per provider and cleared on failure, so a first call during a network outage does not poison the provider for the rest of the session.periodIsowas alwaysnull, because expo-iap 5.x publishes nosubscriptionPeriodISO: iOS splits it intosubscriptionPeriodUnitIOS+subscriptionPeriodNumberIOS, and Android buries it in the first pricing phase of the first subscription offer. This was the most damaging one —deriveProductFieldscomputespricePerDay/pricePerWeek/pricePerMonth/pricePerYearandsavingsPctfromperiodIsoalone, so a null did not degrade them, it removed them, and an unknown variable interpolates to EMPTY rather than to a literal. A per-week-framed paywall silently lost its headline number.requestPurchasewas sent the wrong shape. 5.x wants{ request: { ios, android }, type }; the old flat{ request: { sku } }reached neither platform branch, so StoreKit received an undefined sku.type("in-app"/"subs") is now derived from the store product.finishTransactionwas never called, so StoreKit re-delivered every transaction on each launch.
Changed
expoIapProductProvider.purchase()resolves"pending"where it used to resolve"purchased", whenrequestPurchaseresolvesnull— which is the normal expo-iap 5.x outcome, because the transaction is delivered topurchaseUpdatedListenerinstead. Reporting"purchased"there granted access for a purchase that had not completed and might still fail. Hosts whose buy button relies ononSuccessfiring on this path must declareonPending(added below) — that is what it is for.
Added
onPending?: ButtonAction[]onPurchaseButtonAction(type + schema), mirroring@rocapine/react-native-onboarding-ui's dispatcher. A"pending"result is unconfirmed, not successful — a Stripe Payment Link purchase always resolves it, and so now does an expo-iap purchase awaiting its listener.
[1.70.0] - 2026-08-26
Changed
- BREAKING —
Paywall.placementis nowPaywall.moment.placementwas a column onpaywalls; the addressable entity is now amoment, and the key a host passes topresent()ismoments.key. Same meaning, new name. Onboarding Studio already serves this shape, so a host on the old field readsundefined. - BREAKING —
audienceId/audienceNamemoved fromPaywallCatalog.metadataonto eachPaywall. Each moment now runs its own independent audience waterfall, so two entries in one response can legitimately have matched different audiences. A single catalog-level field could no longer describe that; keeping one would have been quietly wrong rather than merely imprecise. - BREAKING — the
ONBS-Audience-Idresponse header is nowONBS-Audience-Ids, a parallel array alongsideONBS-Paywall-Ids, because a response carries several moments and each resolves its own audience.
Added
- Stripe as a third billing path.
ProductRef.stripecarries a pre-created Stripe Payment Link plus the authored price, and the newstripeLinkProductProvidersynthesises aResolvedProductfrom it with no network call — listing a Stripe price needs a secret key, and by design nobody holds one.ResolvedProduct.storegains"stripe". PaywallProvidergains astripeProductProviderprop. The catalog's product union is resolved through both providers and the runtime published is the one matching the presented paywall'sbilling, because the runtime is a single map keyed by product key — astoreand astripepaywall both declaringyearlywould otherwise fight overproduct.yearly.price.Paywall.billing("store" | "stripe") on the wire type.productRefIdentity, now the single enumeration ofProductRef's identity fields, replacing two hand-maintained copies that never failed loudly when stale.
Fixed
PaywallProvider's doc comments no longer refer toplacement.
Notes on the Stripe path
purchase()resolves"pending", never"purchased"— the browser leaves the app and on web the JS context is destroyed. The entitlement arrives via RevenueCat's Stripe integration, matched onclient_reference_id, which must be the RevenueCat App User ID.purchase()fails closed if that value is absent rather than taking money that can never be attributed; genuine anonymous checkout is an explicitallowAnonymousopt-in.- A
"pending"result runs no ButtonActions (onSuccess/onErrorare not dispatched), so an authored Stripe buy button cannot yet dismiss the paywall or navigate. Closing this needs anonPendingaction or a host callback and is the top follow-up — the Stripe path is not usable end to end until then. - Authored prices are not reconciled with Stripe; a price changed in Stripe and not in the studio renders stale.
[1.69.0] - 2026-08-21
Added
usePaywall().catalogStatus—"loading" | "ready" | "revalidating" | "error", exported asCatalogStatus.isReadyis a single boolean over at least three distinct situations (no catalog yet, a catalog whose products are still resolving, and a failed query — which also presents ascatalog === null), so a host deciding "wait for the catalog" versus "fall back to another paywall engine" could not tell them apart, and every host needing that distinction ended up building its own multi-input gate.usePaywall().productsStatus— the other half ofisReady, so a host seeingcatalogStatus: "ready"withisReady: falsecan tell it is waiting on the store rather than on us.
Notes
"revalidating"is the state this was actually built for, and it is not cosmetic. In production the catalog is served CACHE-FIRST from AsyncStorage under a key that is not scoped bycustomAudienceParams— the react-query key is param-scoped, the disk key is a bare constant (getPaywalls.query.ts/infra/queries/cacheKey.ts). So a host sending volatile params gets an instantly-available catalog resolved under different params, with a fresh fetch in flight behind it. That catalog is non-null, so it reads as ready, and a host gating oncatalog.paywalls[placement]can conclude the placement does not exist and route away milliseconds before the correct catalog lands.- Reported from a production pilot, where the consequence was specific: for an audience gated on a threshold (
hoursSinceOnboardingPaywall >= 44), the launch on which a user first becomes eligible was served the PRE-threshold catalog, so the arm under test lost exactly the launch that mattered — turning an A/B into a measurement of cache behaviour.catalogStatus === "revalidating"is how a host now distinguishes "this catalog is final" from "this may be superseded in a moment", and therefore whether a missing placement means absent or not-yet. - The disk cache is deliberately left unkeyed. Scoping it by params would cause a miss on every param change and destroy the fast first paint the cache exists for, which is the correct trade-off for the common case of stable params. Exposing the state is strictly more useful than changing the caching, and was what the reporting host asked for.
- A present catalog outranks an error on purpose: when a background revalidation fails, react-query keeps the cached
dataand setserror, and a usable catalog must not be reported as a failure. - Additive.
isReadyis unchanged and still the right single check for "presenting now will not show a spinner".
[1.68.2] - 2026-08-21
Notes
- No headless changes. Version moves in lockstep with
@rocapine/react-native-onboarding-ui, which fixes aCarouselpagination dot announcing "Slide 1 of 6 - undefined" to screen readers. See that package's changelog — it also records the Metro-cache gotcha when testing 1.68.1's Carousel fix.
[1.68.1] - 2026-08-21
Notes
- No headless changes. Version moves in lockstep with
@rocapine/react-native-onboarding-ui, which pins thereact-native-reanimated-carouselpeer range to^4.0.0— a fresh install was resolving v5, whose named-only export made theCarouselelement renderundefinedand red-box on device. See that package's changelog.
[1.68.0] - 2026-08-21
Added
product.<slot>.pricePerDay/pricePerDayAmount— the per-day price, exposed at last.deriveProductFieldsalready computedperDay(p)and derived week/month/year andsavingsPctfrom it, then discarded the value itself, so the per-day framing that anchors most trial paywalls ("$0.43 / day" beside "$39.99 / quarter") could not be authored at all despite the number existing. Projected as a flat dotted variable like its siblings and covered by the exhaustive key-list test.
Notes
- Absent whenever the period is unparseable, exactly like
pricePerWeek/Month/Year—perDayneeds a period. That matters because the failure is indistinguishable from "the feature did not ship": a product resolved without a period (or, in the studio editor's indicative preview, a catalog row with noduration_iso) yields an empty string rather than an error. There is a test pinning this. - A misspelled product variable fails silently.
interpolaterenders an unknown{{key}}as an EMPTY STRING, never as the literal template, so{{product.yearly.pricePerDya}}ships as a blank where a price should be and reads as a styling bug. Now that prices are authorable directly onRadioGroup/CheckboxGroupcards (1.67.1) that is the one authoring error that reaches production invisibly — called out in thecompose-screen-builderskill.
[1.67.1] - 2026-08-21
Fixed
- A malformed element tree could crash the app instead of failing validation.
UIElementSchemawas a plainz.unionof ~27 recursive variants, so it tried every branch at every node and each container branch re-parsed the whole subtree on the way — making any shape that missed on all of them exponential rather than linear. Three consequences, all reproduced against a real 52-node paywall, and all crashes rather than errors: everyidstripped exhausted a 512 MB heap in ~10 s ("Ineffective mark-compacts near heap limit"); one container missing itschildrenkey threwRangeError: Invalid string lengthfrom inside zod's own error constructor (the error object was too large to build, so nothing could report it); and even when it did return, the only readable issue wasinvalid_union/ "Invalid input" at the array index.PaywallHostparses serve-path payloads deliberately outside its error boundary — and a boundary cannot catch an OOM anyway — so the first case was an app-kill vector reachable from authored data. Nowz.discriminatedUnion("type", …): all three cases return in single-digit milliseconds with the exact failing path (0.id,0.children,…props.variant), and an unknowntypereports a discriminator miss naming every valid element.
Notes
idbeing required was not a fix for this, it was the trigger.id: z.string()is required on every variant, so a missing one misses every branch at every node — which is precisely what made that case maximal-cost. A required field cannot fail fast inside a non-discriminated union, so "requireidand fail early" was a no-op; the discriminator is what makes it fast.- Every variant now needs exactly ONE literal
type.YStackandXStackare therefore two entries sharing one props schema rather than one entry withz.union([literal, literal]), which a discriminated union cannot key off. Element count is unchanged (27) and no previously-valid payload becomes invalid. - Error messages change shape for invalid payloads — precise paths instead of
invalid_union. Nothing that parsed before parses differently now.
[1.67.0] - 2026-08-21
Fixed
- One refused presentation permanently disabled paywalls for the rest of the process. iOS will not present a view controller over one that is already presenting — another
Modal, apresentation: "modal"route, a StoreKit alert.present()had already set the active placement by then, but the host's Modal never appeared, so nothing ever calledcomplete(): the pending promise never settled, the placement stayed set for the life of the app, and every laterpresent()— for any placement — resolved"error"with no error and no log. Confirmed in production on a monetisation surface, where the failure is invisible to the host and to us. The existing self-heal structurally could not catch it: that one requiresactivePaywallto be null, and here it is non-null (the catalog holds the paywall perfectly well — only the platform refused to show it).PaywallProvidernow abandons a presentation the host never confirmed, afterpresentAckTimeoutMs(default 5000 ms), resolving{status:"error", reason:"host-never-presented"}and logging why. An acknowledgement rather than a bare timeout, because a paywall a user is reading legitimately stays active for minutes, so elapsed time alone cannot tell "still on screen" from "never appeared" — only an unacknowledged presentation is ever torn down.
Added
PresentResult.reason— every"error"now says WHY:unknown-placement,already-presenting,parse-error,render-error,host-never-presented,paywall-disappeared. The bare status conflated conditions whose correct recovery is opposite:unknown-placementmeans the catalog may not have arrived yet and retrying is right,already-presentingmeans retrying is wrong and something may be stuck. A caller given only the status could act correctly on neither, and two separate multi-hour production investigations were spent reconstructing by elimination what this value already knew. Exported asPresentErrorReasonso a host can switch exhaustively.PresentResult.activePlacement— set alongsidereason: "already-presenting", naming the placement that holds the surface. The same placement means the caller double-called and wants its own in-flight guard; a different one means something else is stuck, which the caller cannot fix. Different diagnoses, so the bare status served neither.presentAckTimeoutMsonPaywallProvider— tunes the window above.nulldisables the recovery, which reinstates the permanent-wedge failure; only pass it if the host cannot acknowledge.acknowledgePresentationon the paywall context (usePaywallHost()) — how a host confirms a paywall genuinely reached the screen.@rocapine/react-native-onboarding-uiwires it to its Modal'sonShow, which never fires when the platform refuses; that is what makes it the right signal.
Notes
- Both packages must move together for this release. The recovery depends on the host acknowledging, so a newer headless paired with a
-uiolder than 1.67.0 would never receive an acknowledgement and would abandon legitimate presentations after the timeout. The two packages share a version by policy andnpm run publish:allships them together, so this is a caveat for hand-pinned installs, not the normal path.
[1.66.0] - 2026-08-19
Notes
- No headless changes. Version moves in lockstep with
@rocapine/react-native-onboarding-ui, which fixesenteringSettleDelayMsbeing unreachable fromOnboardingPage— see that package's changelog.
[1.65.0] - 2026-08-19
Added
animation.entering.once— play an entrance exactly once per screen lifetime, on the first render where the element is visible. Fixes two bugs that share one cause:renderWhenvisibility is mount/unmount (a false gate returnsnull) while reanimated firesenteringon mount, so a gated element replays its entrance every time the gate flips back to true — swipe away from a carousel slide and back, and its decorations animate in again. No payload-level workaround exists:gtestill unmounts when you move backwards past the threshold, andreplayWhenis the exact opposite (it remounts on every change), so the latch has to live in the SDK.- An initial-mount play is DEFERRED, not suppressed. If the first visible render is the screen's own mount, the entrance waits until the screen has settled. An entrance fired during the host navigator's push transition is half-consumed by it — with staggered delays, the early ones run under the transition and the late ones land after, so the reveal reads as half-animated — and on a cold run remote images may not have decoded either. Suppressing would have traded a partial entrance for none, which is the bug rather than the fix. Scope, stated precisely because it is easy to over-claim: the deferral buys clear air from the entry transition; it does not wait on image decode, because nothing in React Native reports that. Delaying does hand decode a head start, but as a side effect rather than a guarantee. Later visibility flips never replay;
oncewins overreplayWhenwhen both are set.
Notes
- Fully opt-in. Nothing changes for an element that does not set it, and screen-entrance choreography is untouched — a blanket "never animate on initial mount" would have broken that for every screen.
[1.64.0] - 2026-08-19
Added
progressHeadercovers the last two values a forked bar needed.backButtonStrokeWidth(chevron stroke weight, default2) —backButtonSizecovered the glyph's size but not its weight, and at a 20pt glyph the difference between2and2.5reads as "the icon changed" without anyone being able to say why.paddingTop(space above the bar, default0) — the block hadpaddingBottombut nothing for the top, so a fork's extra space above the bar had no expression and the header sat higher after retirement.paddingTopis added to the top safe-area inset rather than replacing it. The inset is not optional, so a field that replaced it would let a payload push the header under the notch.paddingBottomhas no inset to compose with, which is why only this one is additive — the asymmetry is spelled out in the type, the renderer and the docs.
Notes
- Both default to the previous values, so nothing moves for anyone not setting them.
- One structural difference is documented rather than fixed. The header is a three-column row (back button / track / right spacer, flex
1 / trackFlex / 1) and the reserved right column cannot be removed, so a fork whose track runs to the right padding edge will see its right end pull inward after retirement.trackFlexshrinks that column proportionally but never to zero. Removing it means a two-column mode — a layout change rather than another optional prop — so it deserves its own decision. Net: a forked bar is retirable at a cost, not at parity.
[1.63.0] - 2026-08-19
Added
RepeatUIElement — one template, N rows. Materializes itschildrenonce per row of a payload-authoredprops.dataarray, replacing the duplicated subtrees that made every copy or style change an N-fold edit. ArenderWhenon the template gates per row, soRepeatalso covers the "show exactly one of N" case — there is deliberately noMatchelement. Row fields read as{{item.<field>}}and asrenderWhenvariables (item.indexalways present);asrenames the scope,keyFieldpicks the row field used for each materialized element's id suffix (card→card__aries) and React key.datais authored in the payload rather than sourced from a variable holding JSON, and a translatable row string carries its own literal i18n key — key coverage is measured by scanning payloads for literal key strings, so a computed key ("zodiac_{{item.sign}}_title") would make the scanner find nothing, report the screen fully translated, and ship untranslated rows. New exports:RepeatElementProps.animation.replayWhenonBaseBoxProps— a variable name. Re-firesenteringwhenever that variable's value changes, so an element can re-animate without disappearing first; previously the only way to replay an entrance was to togglerenderWhen, which coupled "animate again" to "change visibility". The element's subtree is remounted, so transient state inside it resets and a continuouseffectrestarts; the initial mount is not a replay.Image.mode: "plain" | "expression"—expressionenables{{variable}}interpolation inurl, so one element serves a data-driven set instead of one duplicated subtree per case. References resolve to the variable'svalue, not itslabel(the inverse ofText): a URL segment is a machine identifier, so{ value: "aries", label: "Aries" }must yield.../aries.pngand not a 404 on.../Aries.png. Defaults to"plain", which stays fully static.Carousel.progressVariableName— publishes the carousel's continuous swipe position as a screen-scoped animated variable, so siblings can gaterenderWhenon the finger rather than the settled slide (variableNamestill writes only on snap). The published value is normalized to[0, childCount): the underlyingabsoluteProgressis clamped only whenloop: falseand is unbounded underloop: true(which is the default), so the raw value would leave every gate silently dead after the first lap.TypewriterText.reserveSpace— lays the fully-resolved string out invisibly to establish the box and overlays the animating characters, so a reveal never pushes siblings down. Only meaningful withcursor: true(without a cursor every character is already mounted from frame 0 and the box is stable). Measures the real resolved string, so it stays correct per locale, unlike the hardcoded wrapper height it replaces.insetonBaseBoxProps—{ top?, left?, right?, bottom? },number | string, honoured onZStackchildren only, replacing hand-computedtransform.translateX/Yfor off-anchor layers. An omitted side inherits the stack's shared anchor for that axis rather than meaning0; when an axis carries an inset, that axis drops both the opposite side's0and the shared anchor, so placement is correct at every anchor rather than only atflex-start.configuration.progressHeader— typed studio-authored progress-header styling (colours,height,borderRadius, paddings,gap,trackFlex, back-button), plus auseProgressHeaderConfig()hook. No backend change: the edge function already returns the wholeconfigurationblob. New exports:ProgressHeaderConfiguration,useProgressHeaderConfig. The block covers the back button's container as well as its glyph —backButtonBackgroundColor,backButtonBorderColor,backButtonBorderWidth,backButtonContainerSize,backButtonBorderRadius— because glyph fields alone were not enough to retire a fork, which is what the block exists for: the one host known to have forked the bar wraps the chevron in a 32x32 white circle with a 1px border, and with onlybackButtonColor/backButtonSizethat had no reachable expression. A lonebackButtonBorderColorimplies width1(RN defaults it to0, so a lone colour would draw nothing); all five unset render the previous bare chevron in apadding: 4touchable exactly.- Non-fatal payload diagnostics for misplaced keys.
collectUnknownElementKeys/collectUnknownKeysInSteps/formatUnknownElementKeysreport keys sitting at an element's top level that the schema silently drops — classicallyanimationoutsideprops, which parses, renders, and never animates. Each finding carries akind:misplaced(valid prop absent fromprops→ "did you meanprops.X?"),shadowed(valid prop already inprops, so the top-level copy is inert, with aconflictsflag when the two values differ), orunknown. Allowed key sets are derived fromUIElementSchemaat runtime, so they cannot drift.OnboardingProviderruns the check once per payload under__DEV__. Deliberately not.strict(): rejecting unknown keys would turn already-published payloads carrying a stray key into hard parse failures. New exports:UnknownElementKey.
Fixed
- Template URLs are no longer handed to the asset preloader.
extractAssetUrlspushed anyurlprop verbatim, so anImagewithmode: "expression"sent the literalhttps://cdn/{{sign}}.pngto the prefetcher — a guaranteed 404 on every load. Such URLs are now skipped, and aRepeat's template is instead resolved against each row so repeated media genuinely preloads.
[1.62.0] - 2026-08-17
Added
client.getPaywalls()— fetch a project's full paywall catalog in one round-trip. Returns every placement (no per-placement fetch in the common path), cached under a dedicatedrocapine-paywalls-*AsyncStorage namespace with the same stale-while-revalidate / custom-key behaviour as onboarding steps. New exports:Paywall,PaywallCatalog,PaywallOptions,GetPaywallsResponseHeaders,PresentResult.PaywallProviderandusePaywall()— present a paywall from anywhere in the app, including screens with no onboarding flow mounted. MountPaywallProvideronce, aboveOnboardingProvider, not beside or inside it — an app-level ancestor still reaches anOnboardingProvidermounted anywhere underneath.usePaywall()returns{ present, isReady, catalog }:present(placement)shows the matching paywall and resolves once the user leaves it ("purchased" | "dismissed" | "cancelled" | "error"), with no network call — the catalog and its products are already resolved fromPaywallProvidermount, so a paywall renders the instant a user taps upgrade. An unknown placement, or presenting while another paywall is already showing, resolves"error"rather than throwing.- One shared product runtime across both providers.
PaywallProviderandOnboardingProviderpublish/consume the same product context, so passing the sameproductProviderto each — withPaywallProvideras the ancestor — gives a single resolved product set and a singlepurchasingflag visible to both an onboarding step'spurchaseaction and a standalone paywall's. dismissandpresentPaywallButtonActions.dismissfinishes the current screen with{ status: "dismissed" }(a paywall host upgrades this to"purchased"/"cancelled"when a purchase actually completed during that presentation).presentPaywallopens a paywall by placement from an onboarding step or from a paywall's own content, and no-ops (with aconsole.warn) when noPaywallProvideris mounted anywhere above the host. Both were withheld from the1.61.0product-actions release specifically because no paywall host existed yet to make them meaningful.
[1.61.0] - 2026-08-13
Added
- Vendor-neutral product runtime (
src/products/). Store subscriptions resolve at runtime through an injectedProductProvider, so a screen can display live prices and sell without the SDK depending on any billing vendor. New exports:ProductProvider,ProductRef,ResolvedProduct,ProductWithDerived,ProductRuntime,PurchaseResult,RestoreResult,useProducts,deriveAll,deriveProductFields,formatCurrency,parseIsoDuration,productVariables. - Three providers, none a dependency.
revenueCatProductProvider,expoIapProductProvider, andstubProductProvider(demos and previews only).react-native-purchasesandexpo-iapare loaded viatry { require() } catchand are neither dependencies nor peer dependencies — absent, the adapter throws a clear error at call time rather than at import time. - Derived price fields are computed centrally, not by adapters, so every provider yields identical numbers and formatting:
pricePerWeek/Month/Year(string + amount),savingsPct(against a declaredcompareToslot, normalized per day),trialDays. - Products project into the variable bag as flat dotted keys —
product.<slot>.price,product.<slot>.pricePerWeek,product.<slot>.savingsPct, plusproducts.loaded/products.purchasing/products.error.interpolate()andevaluateConditionboth resolve keys by flat lookup, so{{product.yearly.price}}andrenderWhenonproducts.loadedwork with no rendering-engine change. purchaseandrestorepress actions onButtonAction.purchaseinterpolates itsproductfield, so aRadioGroupwritingplancan drive{ type: "purchase", product: "{{plan}}" }. Both acceptonSuccess/onErrorfollow-up action arrays (purchasealsoonCancel;restorealsoonNothingToRestore), which are fullButtonAction[]— so"continue"nested inside one still works.OnboardingProvideracceptsproductProviderandproductRefs(both optional) and publishes aproducts: ProductRuntimeon its context.
Changed
ButtonActionSchemais nowz.ZodType<ButtonAction>rather thanz.ZodUnion. The union became recursive when the follow-up action arrays were added, so it is declared withz.lazyand an explicit type annotation. Union-specific introspection (.options) is no longer available on it; parsing behaviour is unchanged.
Notes
- Prices are never CMS data. Every displayed price comes from a
ProductProvider— App Review rejects a paywall whose displayed price differs from the store.stubProductProviderexists for demos only and must never back a shipped paywall. - A host that passes neither
productProvidernorproductRefsis unaffected:useProductsreturns a referentially stable object forever after mount, so element memoization is preserved. Such apps do gain three variables in the bag (products.loaded="false",products.purchasing="false",products.error=""), computed once per screen mount. dismissandpresentPaywallactions are deliberately not included — they need a paywall host that does not exist yet, and shipping them as no-ops would let authors wire buttons that do nothing.
[1.60.0] - 2026-08-13
Added
UIElementandUIElementSchemaare now public. Both were module-private insteps/ComposableScreen/types.ts, where the step payload schema was their only consumer. They are exported from the newsrc/screens/types.tsand reachable from every existing import path. Purely additive — nothing was removed or renamed.ScreenElementsSchema— the elements array as a first-class, screen-agnostic schema, carrying the nested-KeyboardAvoidingViewrefinement that previously lived on the step payload. A caller parsing a full step still sees the samepayload.elementsissue path; the constraint simply belongs to the element tree rather than to steps, so a non-step screen can reuse it.
Changed
- Element schemas moved to
src/screens/, ahead of the paywall work.src/steps/ComposableScreen/elements/*is nowsrc/screens/elements/*, and theUIElementunion lives insrc/screens/types.ts.src/steps/ComposableScreen/types.tsremains as the onboarding step wrapper (BaseStepType+payload.elements) and re-exports everything screen-agnostic, so every existing import path — including the documenteddist/steps/ComposableScreen/types.jspayload-validation recipe — resolves unchanged. This is the headless half of extracting the rendering engine so it can serve both onboarding steps and paywall screens.
[1.59.2] - 2026-07-24
Added
TypewriterTextacceptspreset: "none"to disable the per-character animation.TypewriterTextElementProps.presetwidens toEnteringPreset | "none"(zod schema accepts the literal too). Omittingpresetstill defaults to"FadeInDown"—"none"is the explicit opt-out.
[1.59.1] - 2026-07-23
Fixed
startStepIdis read fromconfiguration, notmetadata. The backend returns the entry-point id ononboarding.configuration.startStepId, butuseOnboardingStart()read it frommetadata.startStepId(alwaysundefined), so the flow always fell back to the first step regardless of the studio-authored start node.useOnboardingStart()now readsconfiguration.startStepId. ThestartStepIdfield moved fromOnboardingMetadatato the newOnboardingConfigurationinterface (Onboarding.configurationis now typed instead ofany).resolveStartStepNumber(steps, startStepId)is unchanged. Corrects the1.59.0location ofstartStepId.
[1.59.0] - 2026-07-17
Added
- Explicit start node + end-via-branching + a first-class completion callback. The onboarding graph now has studio-authored entry/exit semantics, all optional and backward compatible:
OnboardingMetadata.startStepId— id of the unique step the flow starts on, decoupled from array position. Resolve it with the newresolveStartStepNumber(steps, startStepId)helper or the newuseOnboardingStart()hook (suspends on the payload, returns{ startStepNumber }). Falls back to the first step when absent or dangling.ONBOARDING_END_STEP_ID("__END__") — a reserved end sentinel. A step'snextStep.defaultTargetStepIdor anybranch.targetStepIdmay target it to end the onboarding; ending is a first-class branching outcome, so a decision point can finish the flow from any step with no trailing screen. Exported for host/studio use.OnboardingProvidergained anonComplete?: ({ variables, metadata }) => voidprop, exposed to the host via thecompleteOnboarding()helper (returned fromuseOnboardingStepand available on the headlessOnboardingProgressContext). New exported typesOnboardingCompletionContext/OnboardingCompleteHandler.
Changed
resolveNextStepNumberresolves the end sentinel. It returnsnullwhen the matching branch'stargetStepId— or thedefaultTargetStepId— equalsONBOARDING_END_STEP_ID, in addition to the existing "no valid next" cases. Signature unchanged; payloads that don't use the sentinel are unaffected.BaseStepTypeSchemarejects a stepidequal toONBOARDING_END_STEP_ID. A step named"__END__"would be unreachable (branching to it ends the flow), so the schema now fails validation for it. Real step ids are unaffected.
[1.58.0] - 2026-07-16
Added
- Custom Button action handlers now receive a
setVariablesetter.CustomActionHandlerargs gainedsetVariable(name, { value, label?, kind? }), so a host-registered{ type: "custom" }handler can write back into the ComposableScreen variable context — the imperative counterpart to the declarative{ type: "setVariable" }action. Writes update both the render store (renderWhen/{{interpolation}}) and the branching store (resolveNextStepNumber), so a following"continue"branches on the new value. Backward compatible — existing handlers destructuring only{ variables }are unaffected.
[1.57.4] - 2026-07-09
Fixed
preloadAssetsnow prefetches images into the memory cache. The batchedImage.prefetch(urls)call passed nocachePolicy, so expo-image warmed only the"disk"cache — the first on-screen decode still flashed. It now prefetches with"memory-disk", matching the render-sidecachePolicy(onboarding-ui 1.57.4), so preloaded images are ready in memory.
[1.57.3] - 2026-07-09
- No headless changes — version kept in lockstep with
@rocapine/react-native-onboarding-ui1.57.3 (ComposableScreen bordered-image corner fix).
[1.57.2] - 2026-07-09
- No headless changes — version kept in lockstep with
@rocapine/react-native-onboarding-ui1.57.2 (ComposableScreen keyboard-avoiding background fix).
[1.57.1] - 2026-07-09
- No headless changes — version kept in lockstep with
@rocapine/react-native-onboarding-ui1.57.1 (ComposableScreen loaderrenderWhenfix).
[1.57.0] - 2026-07-01
Changed
- Version bump to stay in lockstep with
@rocapine/react-native-onboarding-ui1.57.0 (ComposableScreen render-performance refactor lives in the UI package). No functional changes to the headless SDK.
[1.56.0] - 2026-06-30
Added
TypewriterTextUIElement — new ComposableScreen element that reveals itscontentstring one character at a time (per-char delay =delay + charIndex * stagger). Props:content(required),mode(plain/expression),preset(entering preset, defaultFadeInDown),duration(400),delay(0),stagger(45),easing,spring(wins overeasing),loop+loopDelay(repeat mode),cursor+cursorChar(blinking caret), plus the standard text-style props and allBaseBoxProps. Distinct from the whole-blockanimation.enteringand fromAnimatedText(number counter). Leaf, non-interactive.- Exported
EnteringPresetSchema/AnimationEasingSchema/SpringConfigSchemafromBaseBoxPropsso element schemas can reuse the entering-preset enum without duplicating it.
[1.55.1] - 2026-06-26
Added
ZStackjustifyContent/alignItems— theZStackelement schema now accepts these enums to anchor each content-sized layer within the stack (e.g. a floating bottom CTA withjustifyContent: "flex-end"). Additive; defaults preserve prior top/stretch layering.
[1.55.0] - 2026-06-25
Added
- Radial
backgroundGradient—GradientBackgroundis now a discriminated union oflinearandradial. A radial gradient is{ type: "radial", center?: { x, y }, radius?, stops }:centeris in 0–1 box fractions (default{ 0.5, 0.5 }),radiusis a 0–1 box fraction (default0.75), and eachstopis{ color, position? }(same as linear). Available on every element viaBaseBoxProps. Existing linear gradients are unchanged.
[1.54.0] - 2026-06-23
Added
cacheKeyclient option +OnboardingStudioClient.clearCache()— opt into app-controlled cache persistence. With nocacheKey(default), production caching is unchanged: stale-while-revalidate under"rocapine-onboarding-studio"(serve cache-first, heal in the background). PassingcacheKeypersists the payload under"rocapine-onboarding-sdk-{cacheKey}"and serves it cache-first with no background revalidation, so a pinned version survives across launches and is never swapped out mid-flow — useful for resumable onboardings. The host triggers a refetch via the newclearCache()(removes the client's namespaced key; pair with invalidating the["onboardingQuestions", …]React Query key for an in-session refetch). The cache key is also part of the React Query key now, so clients with different keys no longer dedupe. Sandbox mode still always fetches fresh. HelpersgetOnboardingCacheKey/DEFAULT_ONBOARDING_CACHE_KEYare exported.
[1.53.0] - 2026-06-22
Added
DatePicker.formatprop — optionalIntl.DateTimeFormatOptionssubset (weekday,year,month,day,hour,minute,second,hour12,hourCycle,dateStyle,timeStyle) on theDatePickerelement schema (DateTimeFormatOptionsSchema/DateTimeFormatOptions), controlling how the picker's stored/displayed label is formatted acrossdate/time/datetimemodes. Lets authors choose 12h vs 24h, day/month/year style, etc. (e.g.{ hour: "2-digit", minute: "2-digit", hour12: false }→"14:30"). Omit for the previous default medium-style label. Note: Intl throws ifdateStyle/timeStyleis combined with component fields — don't mix them (schema does not enforce).
[1.52.0] - 2026-06-22
Added
headerHeight+setHeaderHeightonOnboardingProgressContext, and theuseOnboardingHeaderHeighthook. The host-renderedProgressBaris absolute-positioned, so its real footprint (top safe-area inset + bar + padding) was never available to step content. The bar now measures itself and publishesheaderHeight(its full pixel footprint, including the inset it spans;0when hidden) so content can offset below it instead of guessing a fixed height. Consumers that already apply the top inset themselves should add onlyheaderHeight - insets.topto avoid double-counting.
[1.51.2] - 2026-06-22
Changed
- Version parity bump. No headless SDK changes; released to stay in lockstep with
@rocapine/react-native-onboarding-ui1.51.2 (UI-only fix: RadioGroup/CheckboxGroup container now honorsflex/flexGrow/flexShrink).
[1.51.1] - 2026-06-22
Changed
- Version parity bump. No headless SDK changes; released to stay in lockstep with
@rocapine/react-native-onboarding-ui1.51.1 (UI-only fix: centered RadioGroup/CheckboxGroup item labels).
[1.51.0] - 2026-06-19
Added
RadioGroup/CheckboxGroupper-itemimage. Eachitems[]entry accepts an optionalimage: { url, width?, height?, aspectRatio?, resizeMode?, borderRadius? }, rendered above the label/sub-label as a column (image → label → subLabel). Validated in both step schemas (emptyurland invalidresizeModerejected).RadioGroup/CheckboxGroupitem layout props. New group-levelitemAlignItems("flex-start" | "center" | "flex-end" | "stretch", default"center") controls the cross-axis alignment of each item's contents, anditemGap(number, default12) controls the spacing between an item's inner pieces (tick ↔ content, image ↔ text).
[1.50.1] - 2026-06-19
Changed
- Version parity bump. No functional changes to the headless SDK; released alongside
@rocapine/react-native-onboarding-ui1.50.1 (UI-onlyRadioGroup/CheckboxGrouptick-at-end layout fix).
[1.50.0] - 2026-06-19
Added
- ComposableScreen
RadioGroup/CheckboxGroup— tick + sub-label customization. New tick propstickPosition("start"|"end", default"start"),tickColor,tickSelectedColor,tickBorderRadius(default: radiotickSize / 2full circle, checkbox4), andtickSize(tick diameter / box side in px, default20— radio's inner dot and checkbox's ✓ glyph scale with it). Each item now accepts an optionalsubLabel(secondary line) with state-aware styling:itemSubLabelColor,itemSelectedSubLabelColor,itemSubLabelFontSize,itemSubLabelFontWeight,itemSubLabelFontFamily,itemSubLabelFontStyle. Itemlabelis now optional — when a label or sub-label is absent no gap is rendered.
[1.49.1] - 2026-06-19
Changed
- Version parity bump — no headless SDK changes. Released alongside the UI fix for ComposableScreen Carousel active dot sizing.
[1.49.0] - 2026-06-19
Added
- ComposableScreen
Carouselelement — more dot controls. New propsactiveDotWidth,activeDotHeight(active-dot size; default todotWidth/dotHeightwhen unset),dotsPosition("top"|"bottom", default"bottom"), anddotsMarginBottom(default0). Complements the existingdotColor/activeDotColor/dotWidth/dotHeight/dotsGap/dotsMarginTop.
[1.48.0] - 2026-06-19
Added
- Carousel pagination dot customization — the
Carouselstep payload accepts an optionalpaginationobject:show,dotColor,activeDotColor,dotWidth,dotHeight,activeDotWidth,activeDotHeight,gap,position("top"|"bottom"),marginTop,marginBottom. All fields optional; omittingpaginationkeeps the previous default look.
[1.47.0] - 2026-06-19
Added
- Injectable navigation — new
OnboardingNavigationAdaptertype, defaultexpoRouterAdapter, anduseOnboardingNavigation()hook.OnboardingProvideraccepts an optionalnavigationprop to plug in any navigation library (react-navigation, custom) instead ofexpo-router.
Changed
expo-routeris now an optional peer dependency (was a hidden hard import). When installed it is used automatically; otherwise inject anavigationadapter.useOnboardingStepcallsnavigation.useFocusEffectinstead of importingexpo-routerdirectly. Existing expo-router apps require no changes.
[1.46.0] - 2026-06-18
Added
- New
DrawingPadComposableScreenUIElementtype (type + Zod schema). A freehand drawing / signature surface that serializes the captured drawing into runtime variable(s):variableNamereceives an SVG path string,imageVariableNamereceives a base64 image data URI. Props:strokeColor,strokeWidth,backgroundColor,clearable,imageFormat("png"|"jpeg"), a customizable clear button (clearButtonPosition(4 corners),clearButtonOffset,clearButtonSize,clearButtonColor,clearButtonIconColor,clearButtonLabel), plus allBaseBoxProps. The renderer (UI package) requires the optional peer dependency@shopify/react-native-skia.
[1.45.0] - 2026-06-18
Added
SliderComposableScreen UIElement — a continuous numeric input bound to a variable. Value is stored as a stringified float (kind: "float") so expressions/conditions coerce it numerically. Props:variableName,defaultValue(number),min(0),max(1),step(0 = continuous), plusminimumTrackTintColor/maximumTrackTintColor/thumbTintColoranddisabled. Schema refinesmin <= max. Exposed viaSliderElementPropsand theUIElementunion /UIElementSchema.
[1.44.7] - 2026-06-18
Changed
- Version sync with
@rocapine/react-native-onboarding-ui@1.44.7(gradient elements no longer fill the screen). No headless changes.
[1.44.6] - 2026-06-18
Fixed
- Asset prefetch/preload now works for
ComposableScreensteps. The element tree walker inextractAssetUrlsrecursed intoprops.children, but theUIElementschema storeschildrenas a top-level sibling ofprops, so container recursion never fired — every nestedImage/Video/Lottie/Riveasset was skipped (composable screens always wrap content inSafeAreaView/ScrollView/stacks). Now recurses intoelement.children, so nested assets warm the cache as intended.
[1.44.5] - 2026-06-16
Changed
- Version sync with
@rocapine/react-native-onboarding-ui@1.44.5(staggered autoplayProgressIndicatorloader bars no longer reset to empty). No headless changes.
[1.44.4] - 2026-06-16
Changed
- Version sync with
@rocapine/react-native-onboarding-ui@1.44.4(empty/nullfontFamilynow falls back to the theme default). No headless changes.
[1.44.3] - 2026-06-16
Fixed
- Production no longer gets pinned to the offline fallback. The production
onboarding query was cache-first with
staleTime: Infinity: it returned the AsyncStorage cache and never called the edge function again, and it cached whatevergetStepsreturned — including the offline fallback. So a single first-launch fetch failure (timeout / offline / cold-start) cached the fallback and pinned every subsequent launch to it, while the device stopped hitting the studio entirely. The query now (1) never caches the fallback (detected via theONBS-Onboarding-Id: "fallback"header), so a bad launch self-heals on the next start, and (2) uses stale-while-revalidate — it serves the cache for an instant first paint while refreshing from the network in the background, so studio re-deploys propagate and a stale cache recovers.
[1.44.2] - 2026-06-15
Fixed
- Italic font faces are no longer dropped. The runtime font registry keyed
variants by weight only, so a
700-italicface was overwritten by the700-normalface at the same weight — anyfontStyle: "italic"text then rendered upright. Variants are now keyed by weight + style, both faces are registered, andresolveFontFamily/useResolvedFontStyle/useResolvedFontFamilyaccept an optionalfontStyleargument and pick the italic face when requested (falling back to the upright face when no italic is registered at that weight). - Apple SF Pro fonts now render at all weights on iOS. A manifest family
whose name collides with the iOS system font (
SF Pro,SF Pro Display,SF Pro Text,SF Pro Rounded,system, … — matched case- and separator-insensitively) is no longer registered as a bundled face on iOS: registering under the system family name made iOS give the system font precedence, so only Regular resolved (other weights rendered as tofu). On iOS such families now resolve tofontFamily: undefinedso React Native uses the real system font honoringfontWeight. On Android (no SF Pro system font) the bundled faces register and resolve normally.
Added
isSystemFontFamily,normalizeStyle, and theFontStyleKey/RegisteredFacetypes are now exported from the package.
[1.44.1] - 2026-06-15
Changed
- Runtime font registration — fonts now register under their file's
PostScript name (the font file's base name, e.g.
Inter-SemiBold.ttf→Inter-SemiBold) instead of a synthesized<family>-<weight>name.buildRegisteredNamenow derives the name from the font URL, stripping directory, query string, and extension.
[1.44.0] - 2026-06-11
Changed
- Version bump only — paired with
@rocapine/react-native-onboarding-ui1.44.0 (OnboardingPagekeyboardVerticalOffsetprop). No headless changes.
[1.43.0] - 2026-06-11
Added
ProgressiveBlurImageblurAppear— optional{ delay?, duration?, easing? }on the element schema. Drives a delayed fade-in of the blur layer over the always-visible sharp base image (the photo shows immediately, then the progressive blur arrives). Omitting it keeps the legacy static-on-mount blur.easing∈"linear" | "ease-in" | "ease-out" | "ease-in-out". New exportedBlurAppeartype.
[1.42.1] - 2026-06-11
Changed
- Version sync — no functional change to the headless SDK. Released alongside
@rocapine/react-native-onboarding-ui@1.42.1(Buttonflexfix) to keep both packages on the same version.
[1.42.0] - 2026-06-10
Added
- RadioGroup / CheckboxGroup per-item shadow — new optional props on both element schemas:
itemShadowColor,itemShadowOffset({ width, height }),itemShadowOpacity(0–1),itemShadowRadius(≥0), anditemElevation(≥0, Android). Applied to each item row.
[1.41.2] - 2026-06-10
Changed
- Version sync only — no headless changes. Bumped in lockstep with
@rocapine/react-native-onboarding-ui1.41.2 (appliesshadow*props on Stack/ZStack containers).
[1.41.1] - 2026-06-09
Changed
- Version sync only — no headless changes. Bumped in lockstep with
@rocapine/react-native-onboarding-ui1.41.1 (fixes a statictransformbeing suppressed until an element's entering animation finished).
[1.41.0] - 2026-06-09
Added
autoFocusprop onInputelement — whentrue, the input focuses on mount and the keyboard opens automatically. Optional, defaults tofalse.
[1.40.0] - 2026-06-09
Added
- Background asset preloader — once the onboarding payload is fetched, every remote image/video/Lottie/Rive/SVG asset referenced anywhere in the flow is warmed in the background so later screens render without a load flash. Fully non-blocking (never gates first render) and always on (no config). Covers ComposableScreen element trees (
Image/ProgressiveBlurImage/Video/Lottie/Rive, recursing through container children),MediaContent,Carousel, andLoaderdidYouKnowImages. Bundled assets (MediaSourcelocalPathId) are skipped — only remote URLs are warmed. - New exports —
extractAssetUrls(onboarding)(pure: returns dedupedAssetRef[]of remote assets, safe on partial/malformed payloads) andpreloadAssets(assets)(fire-and-forget; native image prefetch via expo-image/RN Image, HTTP-cache warm for video/Lottie/Rive/SVG with bounded concurrency).AssetRef/AssetKindtypes exported. Hosts can call these manually for custom preloading.
Changed
expo-imageadded as an optional peer dependency — used for batched image prefetch when present; falls back toImage.prefetchfrom react-native when absent. No-op if neither warms.
[1.39.0] - 2026-06-08
Added
AnimatedTextUIElement schema — type + Zod schema for the new count-up text element (from/to/duration/delay/easing/autoplay/loop/decimals/thousandsSeparator+ text styling). Added to the ComposableScreenUIElementunion.tois required; the element renders the number only and never writes a variable. See the UI package for the animatedTextInputrenderer.
[1.38.2] - 2026-06-08
Changed
- Version sync only — no headless changes. Bumped in lockstep with
@rocapine/react-native-onboarding-ui1.38.2 (UI-side fix: memoizeAnimatedBoxentering/exiting/layout builders so entry transitions don't restart on re-render).
[1.38.1] - 2026-06-08
Changed
- Version sync only — no headless changes. Bumped in lockstep with
@rocapine/react-native-onboarding-ui1.38.1 (UI-side re-render fixes in the Loader animations and ComposableScreen render tree).
[1.38.0] - 2026-06-08
Added
ProgressIndicatorvalue range (minValue/maxValue/step) — the indicator is no longer fixed to 0–100.minValue(default 0) andmaxValue(default 100) set an arbitrary value range, soautoplayanimatesinitialValue → maxValueand the boundvariableName/ label carry the raw value (not a percentage). Enables an animated count-up to N:{ minValue: 0, maxValue: 5000, step: 50, autoplay: true, variableName: "…" }, then read{{var}}in aText(mode: "expression").step(default 1,> 0) snaps the displayed/written value and bounds the per-sweep write count to(maxValue − minValue) / step— use a coarse step for large ranges.ProgressIndicator.labelSuffix— suffix appended after the label value (default"%"); set""or a unit (e.g." kg") for non-percentage ranges.
Changed
ProgressIndicatorvalue/initialValueno longer capped at 100 — their Zod.min(0).max(100)was relaxed to a plain number; out-of-range values clamp to[minValue, maxValue]at runtime instead of failing parse. Defaults (minValue:0,maxValue:100,step:1,labelSuffix:"%") keep existing percentage payloads byte-identical.
[1.37.0] - 2026-06-08
Added
onPresson every UIElement —BaseBoxPropsnow carries an optionalonPress: ButtonAction[], so any element can be made tappable with the same action list asButton.actions("continue"/{type:"setVariable"}/{type:"custom"}, run sequentially,"continue"terminal). Flows automatically to every ComposableScreen element variant via the sharedBaseBoxProps. The UI runtime ignores it on elements that own their own gesture (Button,RadioGroup,CheckboxGroup,DatePicker,Input,WheelPicker) — see the UI changelog.arrayOpon thesetVariableaction —SetVariableButtonActiongains an optionalarrayOp: "append" | "remove" | "toggle"that treats the target variable as the JSON-encodedstring[]multi-select used byCheckboxGroup.value/labelare the single member to add (dedup), drop, or flip; the storedlabelstays comma-joined like a real checkbox andkindis ignored. Lets any element (viaonPress) orButtonadd/remove a chip from a multi-select without aCheckboxGroupwidget. OmittingarrayOpkeeps the existing overwrite behavior.
Changed
ButtonActionmoved tocommon.types.ts—ButtonAction,CustomButtonAction,SetVariableButtonActionand their Zod schemas now live insteps/common.types.ts(shared with the newonPress), re-exported fromsteps/ComposableScreen/elements/ButtonElement.tsfor back-compat. No change to the public API surface or payload shape.
[1.36.2] - 2026-06-08
Changed
- Version alignment — no headless changes; bumped to stay in lockstep with
@rocapine/react-native-onboarding-ui1.36.2 (ComposableScreen text-element font fallback fix).
[1.36.1] - 2026-06-04
Changed
- Expo SDK 56 / React Native 0.85 alignment — bumped build-time
react(19.2.3) andreact-native(0.85.3) dev dependencies so the package builds against the SDK 56 toolchain. No runtime/API changes (peer deps stay*).
[1.36.0] - 2026-06-04
Added
blurRadiusprop on theImageComposableScreen element — optional non-negative number applying a uniform Gaussian blur (nativeImage.blurRadius, no extra dependency).0/omitted = sharp; ignored for SVGs.- New
ProgressiveBlurImageComposableScreen element — a full-bleed image with a gradient-masked Gaussian blur baked in (sharp where themaskis transparent, progressively blurred where it's opaque — the "welcome screen" hero look). Props:url,intensity(0–100, maps to a blur radius),tint(light/dark/default),mask,maxBlurOpacity, plus standard box props. Themaskis a union — linear ({ from, to, stops },typeoptional) or radial ({ type:"radial", center?:{x,y}, radius?, stops }); each stop'sopacity= blur strength. Existing{ from, to, stops }payloads stay valid as linear. Leaf element (nochildren); intended as the bottom layer of aZStack. New exported typesLinearBlurMask/RadialBlurMask. (UI renders this by masking a blurred copy of the image — see the UI changelog.)
[1.35.0] - 2026-06-02
Added
hapticprop onButton,RadioGroup,CheckboxGroupComposableScreen elements — optional enum"none" | "light" | "medium" | "heavy" | "soft" | "rigid"mapping to expo-hapticsImpactFeedbackStyle. Opt-in: absent or"none"= no feedback, so existing onboardings are unchanged. Backed by the sharedHapticStyletype +HapticStyleSchemaenum insteps/common.types.ts.
[1.34.1] - 2026-06-02
Changed
- Example onboarding — added a second WebP image (landscape, 16:9) to the default onboarding's first composable screen, alongside the existing portrait WebP. No schema or API change.
[1.34.0] - 2026-06-02
Added
ScrollViewelementalignItems/justifyContent— two optional props on theScrollViewUIElement controlling cross-axis alignment (alignItems:"flex-start"|"center"|"flex-end"|"stretch"|"baseline") and distribution along the scroll axis (justifyContent:"flex-start"|"center"|"flex-end"|"space-between"|"space-around"). Applied to the scroll content container.
[1.33.0] - 2026-06-01
Added
RichTextcontainer UIElement — a wrapping flex row of childTextelements (words + padded/rounded/rotated "chips" that wrap and align together, e.g. a "Boost your[energy]" marketing title). Because each child renders as a real flex child of a<View>(not a nested<Text>like inlineTextSpans), it honors its own box props —padding,borderRadius,borderWidth,backgroundColor,margin,transform— plusrenderWhenandexpressionmode. Plain-text children are split into one item per word so the row wraps word-by-word like a paragraph (chips flow inline with the text); children with box styling or motion stay atomic.childrenare schema-restricted toTextonly.propsare layout props (gap,alignItems— incl."baseline"—justifyContent,flexWrapdefaulting to"wrap") plus allBaseBoxProps, plus inherited text-style defaults (fontSize,fontWeight,fontFamily,fontStyle,color,textAlign,letterSpacing,lineHeight) — declare the title's base typography once on the container and each childTextinherits it (child overrides win). New exported type:RichTextElementProps. (Distinct from inlineTextSpan, which stays a single text-style-only wrapping paragraph.)
[1.32.0] - 2026-06-01
Added
- Animations / transitions / effects on every UIElement —
BaseBoxPropsgains two optional fields, so any ComposableScreen element can declare motion. Schema mirrorsreact-native-reanimated:presetvalues are the exact reanimated builder names and modifier fields map to builder methods.transform(static):{ translateX?, translateY?, scale?, scaleX?, scaleY?, rotate? (deg) }.animation:{ entering?, exiting?, layout?, effect? }.entering/exiting:{ preset, duration?, delay?, easing?, spring? }. Entering presets:FadeIn(Up/Down/Left/Right),SlideIn(Up/Down/Left/Right),ZoomIn(Rotate/Up/Down/Left/Right/EasyUp/EasyDown),BounceIn(Up/Down/Left/Right),FlipIn(XUp/YLeft/XDown/YRight/EasyX/EasyY),StretchIn(X/Y),RotateIn(DownLeft/DownRight/UpLeft/UpRight),RollIn(Left/Right),PinwheelIn,LightSpeedIn(Left/Right); exiting presets are the matching…Out…names.layout:{ preset, duration?, spring? }—LinearTransition,FadingTransition,SequencedTransition,JumpingTransition,CurvedTransition,EntryExitTransition.effect(continuous loop, not a reanimated builder name):{ preset: "pulse" | "fade" | "rotate" | "shimmer" | "bounce", duration?, delay?, easing?, loop?, minScale?/maxScale? (pulse), minOpacity? (fade), degrees? (rotate) }.
easing("linear"|"ease-in"|"ease-out"|"ease-in-out") andspring({ damping?, stiffness?, mass? }, mirrors.springify(config)and wins overeasing). New exported types:AnimationEasing,SpringConfig,EnteringPreset,ExitingPreset,LayoutPreset,EffectPreset,EnteringAnimation,ExitingAnimation,LayoutAnimation,ElementEffect,ElementAnimation,ElementTransform.
TextSpanextended — inline rich-text spans gainbackgroundColor,opacity(0–1),textTransform("none"|"uppercase"|"lowercase"|"capitalize"),textDecorationColor,textDecorationStyle("solid"|"double"|"dotted"|"dashed"), andlineHeight. All optional, inline-safe (animation/transform remain element-level only — spans are not UIElements).
[1.31.0] - 2026-06-01
Added
- Inline rich text for
Text—TextElementProps.contentis nowstring | TextSpan[]. A span array renders styled fragments inline (nested<Text>) that wrap together on one baseline. NewTextSpantype andTextSpanSchemaexported from the headless package. Span fields (all optional excepttext):text,fontWeight,fontStyle,fontFamily,fontSize,letterSpacing,color,textDecorationLine("none"|"underline"|"line-through"|"underline line-through"). Omitted span props inherit from the parentText. Inmode: "expression",{{variable}}interpolation applies to each span'stext.
Changed
TextElementPropsSchema.contentwidened fromz.string()toz.union([z.string(), z.array(TextSpanSchema)]). Backward compatible — existing string payloads validate and render unchanged.
[1.30.0] - 2026-05-29
Added
ProgressIndicatorUIElement — new ComposableScreen element rendering a linear or circular progress display bound to an int variable (0–100). Schema (ProgressIndicatorElementPropsSchema) and type (ProgressIndicatorElementProps,ProgressEasing) exported from the headless package and added to theUIElementunion. Props (all optional, plusBaseBoxProps):variant("linear"|"circular"),variableName(bound int variable — written each frame during autoplay, read otherwise),value(static 0–100),autoplay,loop,initialValue(0–100),duration(ms),delay(ms before the animation starts),easing("linear"|"ease-in"|"ease-out"|"ease-in-out"),color,trackColor,thickness,size,showLabel,labelColor.
[1.29.0] - 2026-05-29
Added
DatePicker:"now"sentinel for date bounds —defaultValue,minimumDate, andmaximumDatenow accept the literal string"now"in addition to ISO 8601 date strings."now"resolves to the current date/time at render, so a max date that should always be "today" no longer goes stale at module-load time. Schema validation accepts a value when it is"now"or parses viaDate.parse.SetVariableButtonActionSchema/SetVariableButtonAction— exported from the headless package and added to theButtonActionSchemaunion ({ type: "setVariable", name, value, label?, valueMode?, kind? }).
Fixed
ButtonActionSchemarejectedsetVariableactions — the headless union only accepted"continue"and{ type: "custom" }, while the UI package and runtime already supportedsetVariable. Any ComposableScreen payload using asetVariablebutton action failed parsing withinvalid_union. Headless now mirrors the UI variant, fixing the schema drift.
[1.28.0] - 2026-05-29
Added
RadioGroup/CheckboxGroup:showTickprop — both schemas extend with optionalshowTick: boolean(defaulttrue). Whenfalse, the per-item indicator (radio circle / checkbox box) is omitted; the item label and selected background / border styling still render. Lets authors build pill / card-style single- and multi-select groups without the tick glyph.
[1.27.0] - 2026-05-29
Added
- Unary condition operators
is_empty/is_not_empty/is_null/is_not_null— usable inrenderWhen,Button.disabledWhen, andnextStep.branches[].condition. They take novalue(schema makesvalueoptional for these and still required for binary operators).emptyis type-aware (empty/whitespace string, empty array, or unset/null);nullis unset/null only — a set-but-empty""is not null yet is empty. ExportsUNARY_CONDITION_OPERATORS+isUnaryConditionOperator. WheelPickerUIElement — scrolling wheel selector for the ComposableScreen system. Binds a variable viavariableName/defaultValue. Options come from either an explicititems: Array<{label, value}>or an auto-generated numericrange: {min, max, step?, unit?}(exactly one required;unitformats labels as"<value> <unit>"). Styling viaitemColor/itemFontSize/itemFontFamilyplus standardBaseBoxProps. ExportsWheelPickerElementProps,WheelPickerItem,WheelPickerRange,WheelPickerElementPropsSchema, and helpersresolveWheelPickerItems/generateWheelPickerRangeItems(shared with the UI renderer + default collection). Rendered via the optional@react-native-picker/pickerpeer dep (same as thePickerstep).
Fixed
- Condition evaluation now decodes JSON-array variable values — multi-select elements (
CheckboxGroup) store their value as a JSON string ("[]"when empty).evaluateConditiondecodes such strings back to an array before testing, so a fully-deselected group correctly reads as empty: arenderWhen/disabledWhenusingis_not_emptynow hides/disables again when the last item is unselected (previously"[]"was treated as a non-empty string and never fell back).containsagainst these values is now real array membership rather than a substring match.
[1.26.0] - 2026-05-28
Added
IconUIElement:fill+fillOpacityprops —IconElementPropsSchemaextends with optionalfill: string(any CSS color; omit ⇒ Lucide default"none"outlined) andfillOpacity: number(0–1, clamped). Enables filled / tinted Lucide icons (Star,Heart,Bookmark,Circle,CheckCircle2, …) from CMS payload.
Changed
onboarding-example.tsComposableScreen demo — wrappedrootYStack in aScrollViewUIElement so the payload scrolls (page renderer is intentionally a plainView flex:1, seecomposable-screen-runtime.md). HeroStaricon also showcasesfill+fillOpacity: 0.2tint.
[1.25.1] - 2026-05-28
Added
aspectRatioonBaseBoxProps— every UIElement now accepts an optional positiveaspectRationumber, mirroring the React Native style prop. Pair withwidth/heightto derive the other dimension instead of hard-coding both.
[1.25.0] - 2026-05-27
Added
ScrollViewComposableScreen UIElement — new container element wrapping children in a scrollable view. Props (ScrollViewElementProps, extendsBaseBoxProps):horizontal,bounces,showsVerticalScrollIndicator,showsHorizontalScrollIndicator,alwaysBounceVertical,alwaysBounceHorizontal,contentInset(ScrollViewContentInset:{ top, right, bottom, left }, iOS-only),contentContainerPadding,keyboardShouldPersistTaps.KeyboardAvoidingViewComposableScreen UIElement — new container element. Props (KeyboardAvoidingViewElementProps, extendsBaseBoxProps):behavior(KeyboardAvoidingBehavior:"padding" | "height" | "position", defaults to iOSpadding/ Androidheight),keyboardVerticalOffset,enabled.- Schema guard: no nested KeyboardAvoidingView —
ComposableScreenStepPayloadSchemanowsuperRefines the element tree and rejects anyKeyboardAvoidingViewnested inside another, reporting the offending elementid.
Backend note:
onboarding-studioshould mirror both new UIElement types (union + Zod schema + editor picker) and the nested-KAV validation rule, and default thepickerarchetype template to wrap its picker in aKeyboardAvoidingView.
[1.24.0] - 2026-05-27
Added
- Button per-state style overrides —
ButtonElementPropsgainspressedStyle?: ButtonStyleOverrideanddisabledStyle?: ButtonStyleOverride, each aPartialof the overridable Button props (BaseBoxPropsplusvariant,backgroundColor,color,fontSize,fontWeight,fontFamily,fontStyle,textAlign). NestedpressedStyle/disabledStyleare not overridable. NewtransitionDurationMs?: numbercontrols the rest/pressed/disabled animation length (default150). - Shadow fields on
BaseBoxProps—shadowColor,shadowOffset({ width, height }),shadowOpacity(0–1),shadowRadius, andelevation(Android) on every UIElement variant. Currently applied by theButtonrenderer in the UI package; schema accepts them on all elements.
Changed
disabledBackgroundColor/disabledColordeprecated — superseded bydisabledStyle.backgroundColor/disabledStyle.color. Still honored as a fallback whendisabledStyleis absent, so existing payloads are unaffected.
Backend note:
onboarding-studioshould mirror the newpressedStyle,disabledStyle, andtransitionDurationMsButton fields plus the shadow fields onBaseBoxProps, and surface per-state style editors. JSON serialization passes through unchanged.
[1.23.0] - 2026-05-26
Added
renderWhenon every UIElement variant — optionalrenderWhen?: LeafCondition | ConditionGroupfield on every entry of theUIElementdiscriminated union (Stack, Text, Image, Lottie, Rive, Icon, Video, Input, Button, RadioGroup, CheckboxGroup, DatePicker, Carousel, ZStack, SafeAreaView). Reuses the existingLeafConditionSchema/ConditionGroupSchemafromcommon.types— no new condition types. When the condition evaluates falsy against current ComposableScreen variables, the runtime skips rendering the element and its entire subtree. Companion toButton.disabledWhen(visual disabled state) andBranch.condition(flow-level next-step selection); userenderWhenfor in-screen conditional visibility (validation errors, variable-gated sections, etc.).
Backend note:
onboarding-studioshould mirror the optionalrenderWhenfield on every UIElement variant and surface a "Render when" condition picker in the element properties panel, reusing the Branch condition builder. JSON serialization passes through unchanged.
[1.22.0] - 2026-05-11
Added
kindonComposableVariableEntry— optional"int" | "float" | "string"tag on stored variables, exported asComposableVariableKind. Drives expression-mode coercion forsetVariableactions (numeric math vs string concat). Existing code paths ignore the tag, so back-compat is preserved.
Backend note:
onboarding-studioshould optionally surface akindfield onsetVariableactions and on any default variable seeding UI.
[1.21.0] - 2026-05-11
Added
defaultIndexandvariableNameon ComposableScreenCarousel— new optional props onCarouselElementProps.defaultIndex(integer, ≥ 0, nullable) sets the initial page at mount.variableNamebinds the carousel index to a variable in the ComposableScreen variable store:setVariablebutton actions targeting that name scroll the carousel imperatively, and user swipes write the new index back to the variable so other elements (Text{{var}}interpolation, branchingevaluateCondition) can react. Invalid / out-of-range values clamp to[0, children.length - 1]; missing / non-numeric values fall back todefaultIndex ?? 0.
Backend note:
onboarding-studioshould mirror thedefaultIndexandvariableNamefields on the Carousel UIElement schema and surface them in the CMS editor.
[1.20.0] - 2026-05-11
Added
disabledWhenon ComposableScreenButton— new optional prop onButtonElementPropsaccepting aLeafCondition | ConditionGroup(the same schema used byBranch.condition). When the condition evaluates truthy against current onboarding variables, the button blocks all press actions (continue, setVariable, custom) and renders in a disabled visual style.disabledBackgroundColoranddisabledColoronButton— optional per-button overrides for the disabled-state colors. Defaults fall back totheme.colors.disableandtheme.colors.text.disable.evaluateCondition,evaluateLeaf,isConditionGroup,Conditionnow exported from the package root so UI code (and host apps) can reuse the same condition runtime that powers branching.
Backend note:
onboarding-studioshould mirror theseButtonschema fields and reuse the existing condition-builder UI from theBranch.conditioneditor.
[1.19.0] - 2026-05-07
Added
fontFamily: "inherit"on ComposableScreenText/Button/Input—TextElementProps,ButtonElementProps, andInputElementPropsnow typefontFamilyasstring | "inherit". Omitting the prop or passing the literal"inherit"makes the renderer fall back totheme.typography.defaultFontFamily. Zod schemas remainz.string().optional()— the"inherit"literal is just a recognised string, no migration required for existing payloads.
Backend note: The
onboarding-studioserver should surface"inherit"(or omission) as a first-class option when authoring Text/Button/InputfontFamilyso CMS users can opt into the host app's default font.
[1.18.0] - 2026-05-06
Added
fontStyle: "normal" | "italic"on Text-rendering ComposableScreen UIElements. Top-level prop onTextElementProps,ButtonElementProps,InputElementProps. Per-item propitemFontStyleonRadioGroupElementPropsandCheckboxGroupElementProps. All optional; Zod-validated asz.enum(["normal", "italic"]).optional().setVariablebutton action —Button.actionsaccepts a new entry{ type: "setVariable", name: string, value: string, label?: string }that writes directly into the variable map. Useful to capture which branch a user chose before"continue"triggersresolveNextStepNumber. Stored shape matches existing element writes ({ value, label }).OnboardingProgressContext.getVariables()— synchronous getter that returns the latest variable snapshot from a ref. Use it insideonContinuehandlers to feedresolveNextStepNumberwith values just written bysetVariable, since React state reads are stale within the same tick.
Fixed
- Branching with same-tick
setVariable+continue— variables were read from React state in the handler that just wrote them, so branch conditions evaluated against pre-set values and fell through to the default target.setVariablenow updates a ref synchronously alongside the state setter;getVariables()exposes the fresh snapshot.
Backend note: The
onboarding-studioserver must mirror the newfontStyle(anditemFontStylefor RadioGroup/CheckboxGroup) field on the affected UIElement schemas, and the newsetVariablebutton action variant in theButtonActionunion and CMS editor.
[1.17.1] - 2026-05-04
Fixed
- Runtime fonts manifest —
registerFontsnow accepts the array shape returned byonboarding-studio({ family: [{ weight, style, url }, ...] }) in addition to the legacy{ family: { weightKey: url } }map. Previously, iterating an array withObject.entriesproduced numeric indices ("0".."N") as weight keys and passed the variant object asurl, causing native expo-font to throwloadSingleFontAsync expected resource of type Assetand warnings likeFailed to load font "X" weight 8 from [object Object]. The newnormalizeFamilyVariantsdedupes by weight and prefersstyle: "normal"variants over italic.
Added
FontVariantEntryandFontFamilyManifestInputexported types.FontsManifestwidened toRecord<string, FontFamilyManifestInput>so array-shape manifests are typed end-to-end.
[1.17.0] - 2026-04-30
Added
- Runtime font download + load —
Onboardingresponse now accepts an optional top-levelfonts?: FontsManifestfield, whereFontsManifest = Record<string, Partial<Record<FontWeightKey, string>>>. Font files are downloaded and registered viaexpo-font(optional peer dependency) when the onboarding payload is fetched.FontWeightKeyaccepts named (regular,medium,semibold,bold,extrabold) or numeric (100…900) keys, normalized internally. OnboardingProvider.fontsFallback?: ReactNode— rendered while the onboarding payload is fetched and remote fonts are downloading. Defaults tonull.<FontLoaderGate fonts={...} fallback={...}>— standalone gate component that registers fonts and exposes aFontRegistryvia context, for hosts that do not useOnboardingProvider.useFontRegistry()anduseResolvedFontFamily(family, weight)hooks for resolving afamily + weightrequest to the registered font name with a closest-weight fallback (CSS-style font matching).- New exports:
FontWeightKey,FontFamilyManifest,FontsManifest,FontRegistry,registerFonts,resolveFontFamily,normalizeWeight,FontRegistryProvider,useFontRegistry,useResolvedFontFamily,FontLoaderGate.
Changed
OnboardingProvidernow wraps children in an internalOnboardingDataGate(useQuery) followed byFontLoaderGate, blocking render until the onboarding payload is fetched and any declared fonts finish loading. The previousprefetchQuerycall is removed.
Backend note:
onboarding-studioshould mirror the newOnboarding.fontsfield — see the migration prompt in the PR description. ComposableScreen UIElement schemas are unchanged; this is an API-level addition.
[1.16.0] - 2026-04-29
Added
Button.actionsordered action array —ButtonElement.propsnow acceptsactions?: ButtonAction[], whereButtonAction = "continue" | { type: "custom"; function: string; variables?: string[] }. Actions run sequentially on press;awaits any returned Promise; aborts the remaining chain on a thrown error;"continue"is terminal.OnboardingProvider.customActionsprop —Record<string, CustomActionHandler>whereCustomActionHandler = (args: { variables: Record<string, ComposableVariableEntry | undefined> }) => void | Promise<void>. Functions are invoked by name fromButton.actions{ type: "custom", function, variables }, receiving the requested variables filtered from the live ComposableScreen variable map.- New exports:
ButtonAction,CustomButtonAction,ButtonActionSchema,CustomButtonActionSchema,CustomActionHandler,CustomActions,ComposableVariableEntry.
Changed
Button.action?: "continue"is now deprecated but still accepted as a back-compat alias. Whenactionsis absent andaction === "continue", runtime treats it asactions: ["continue"]. CMS payloads should migrate toactions.
Backend note: The
onboarding-studioserver must mirror the newButton.actionsfield in itsComposableScreenUIElement schema (Zod) and CMS editor (ordered list of"continue"or{ type: "custom"; function: string; variables?: string[] }). The legacyactionfield should be kept readable for historical payloads.
[1.15.0] - 2026-04-28
Added
SafeAreaViewUIElement — new container element mirroringreact-native-safe-area-context'sSafeAreaView. Props:mode?: "padding" | "margin",edges?accepting either("top" | "right" | "bottom" | "left")[]or a per-edge object mapping each edge to"off" | "additive" | "maximum". ExtendsBaseBoxProps. Exports:SafeAreaViewElementProps,SafeAreaEdge,SafeAreaEdgeMode,SafeAreaViewElementPropsSchema.
Backend note: The
onboarding-studioserver must be updated to accept and validate the new"SafeAreaView"element type in theComposableScreenUIElement union. MirrorSafeAreaViewElementPropsSchema(with the strict per-edge object) in the backend validation layer and addSafeAreaViewto the CMS editor element-type picker. Run the schema-sync/publish process inonboarding-studio(regenerate Zod schemas, bump validator package, deploy) before publishing this SDK release so CI and runtime payloads do not drift.
[1.14.0] - 2026-04-28
Added
ZStackUIElement — new container type that stacks children on top of each other using absolute positioning. Props: allBaseBoxPropsfields (width, height, padding, borderRadius, overflow, backgroundGradient, etc.). Children fill the container bounds by default, enabling image-with-overlay patterns.ZStackElementPropsandZStackElementPropsSchemaexported from the headless package.
[1.13.1] - 2026-04-28
Added
ZStackUIElement — new container type that stacks children on top of each other using absolute positioning. Props: allBaseBoxPropsfields (width, height, padding, borderRadius, overflow, backgroundGradient, etc.). Children fill the container bounds by default, enabling image-with-overlay patterns.ZStackElementPropsandZStackElementPropsSchemaexported from the headless package.
[1.13.0] - 2026-04-28
Added
-
backgroundGradientonBaseBoxProps— all UIElement types now accept an optionalbackgroundGradientprop alongsidebackgroundColor. Accepts aGradientBackgrounddiscriminated union (currentlytype: "linear"). -
LinearGradientConfig— linear gradient config:fromandtoare namedGradientEdgepositions ("top","bottom","left","right","topLeft","topRight","bottomLeft","bottomRight");stopsis an array of{ color: string; position?: number }(min 2 stops, position 0–1). -
Exports —
GradientBackground,GradientEdge,GradientStop,LinearGradientConfig, andGradientBackgroundSchemaexported from the headless package.
[1.12.0] - 2026-04-28
Added
-
Multi-path branching — every step schema now includes a
nextStepfield (nullable, defaults tonull). Whennull, navigation proceeds linearly. When set, an ordered list ofbranchesis evaluated; the first matching branch wins and navigation jumps tobranch.targetStepId. If no branch matches,defaultTargetStepIdis used as a fallback; if that is absent or unresolved, linear progression applies. -
Branch.conditionnullable — anullcondition on a branch is treated as unconditional (always matches). Useful as a final catch-all entry after guarded branches. -
Condition schema —
LeafConditionSchema,ConditionGroupSchema,BranchSchema, andNextStepSchemaadded tocommon.types.tsand exported from the package. Supported operators:eq,neq,gt,lt,gte,lte,contains,in,not_in. Conditions nest recursively viaConditionGroup(logic: "and" | "or",conditions: Array<LeafCondition | ConditionGroup>).ConditionValueSchemaacceptsstring | number | boolean | Array<string | number | boolean>. -
BaseStepTypeSchema— all per-step Zod schemas now extend a single shared base (id,name,displayProgressHeader,customPayload,continueButtonLabel,buttonSection,figmaUrl,nextStep) via.extend(). Previously each schema declared these fields independently. -
variableNameonQuestionandPicker— optionalz.string().min(1)field. When set, the answer selected on that step is stored in the global variable store under this key and becomes available to branch conditions on subsequent steps. -
Variable store —
OnboardingProgressContextgainsvariables: Record<string, any>andsetVariable(name, value). The store is written by the host app'sonContinuehandler and read byresolveNextStepNumber. -
resolveNextStepNumber(currentStep, variables, steps)— new exported pure function. Returns the 1-indexed step number to navigate to, ornullwhen the flow ends. Resolution order: matching branch →defaultTargetStepId→ linear next →null. Self-referencing targets (branch or default pointing back to the current step) are silently skipped to prevent infinite-loop routing. -
evaluateConditionmodule — pure condition-evaluation logic extracted tosrc/evaluateCondition.tswith no domain dependencies. ExportsevaluateLeaf,evaluateCondition,isConditionGroup, and theConditiontype. -
Test suite — Vitest added as a dev dependency. 75 tests across
evaluateCondition.test.tsandresolveNextStepNumber.test.tscovering all operators, AND/OR nesting up to 3 levels, branch ordering, unconditional branches,defaultTargetStepIdfallback, self-loop guard, and edge cases.
Changed
NextStepSchema.branchesnow defaults to[]— omittingbranchesfrom anextStepobject is valid; callers can set onlydefaultTargetStepId.
[1.11.1] - 2026-04-27
Changed
-
BaseBoxPropsexpanded — all UIElement schemas now inheritminWidth,maxWidth,minHeight,maxHeight,flexShrink,flexGrow,backgroundColor, andoverflowfrom the base. Previously these were missing or inconsistently defined per element. -
StackElement(YStack/XStack) props — now correctly extendsBaseBoxPropsinstead of declaringwidth/heightas number-only standalone fields.widthandheightnow acceptnumber | string(e.g."100%"). Stack-specific props retained:gap,alignItems,justifyContent,flexWrap. -
TextElementprops — now correctly extendsBaseBoxPropsinstead of duplicating margin/padding/border fields. Text-specific props retained:content,mode,fontSize,fontWeight,fontFamily,color,textAlign,letterSpacing,lineHeight. -
InputElementprops — addedfontFamily,lineHeight,letterSpacing. -
ButtonElementprops — removed redundantalignSelfoverride (now inherited fromBaseBoxPropswith the full enum). -
RiveElementprops — renamedautoplay→autoPlay(consistent casing with all other elements). -
CarouselElementprops — added dot style props:dotColor,activeDotColor,dotWidth(default20),dotHeight(default4),dotsGap(default8),dotsMarginTop(default12).
[1.11.0] - 2026-04-24
Added
CarouselUIElement schema forComposableScreen— new discriminated-union variant withtype: "Carousel". Takeschildren: UIElement[]— any renderable UIElement tree as slide content (same recursive system asYStack/XStack). Props:carouselType("normal"|"left-align"|"parallax"|"stack", default"normal"),autoPlay(boolean, defaultfalse),autoPlayInterval(number ms, default3000),loop(boolean, defaulttrue),showDots(boolean, defaulttrue),height(number, optional), plus allBaseBoxProps. Validated byCarouselElementPropsSchema(Zod). ExportsCarouselElementPropstype.
Backend note: The
onboarding-studioserver must be updated to accept and emit theCarouselUIElementvariant inComposableScreenpayloads. MirrorCarouselElementPropsSchemain the backend validation layer and addCarouselto the CMS element-type picker.
[1.10.0] - 2026-04-23
Added
DatePickerUIElement schema forComposableScreen— new discriminated-union variant withtype: "DatePicker". Props:variableName(string, optional — context key; selected date written as ISO 8601 string),defaultValue(ISO string, optional),minimumDate/maximumDate(ISO strings, optional),mode("date"|"time"|"datetime", default"date"),display("default"|"spinner"|"calendar"|"clock"|"compact"|"inline", optional — platform-specific),textColor,accentColor,locale(strings, optional), plus allBaseBoxProps. Validated byDatePickerElementPropsSchema(Zod).
Backend note: The
onboarding-studioserver must be updated to accept and emit theDatePickerUIElementvariant inComposableScreenpayloads. MirrorDatePickerElementPropsSchemain the backend validation layer and addDatePickerto the CMS element-type picker.
[1.9.0] - 2026-04-22
Added
CheckboxGroupUIElement schema forComposableScreen— new discriminated-union variant withtype: "CheckboxGroup". Props:variableName(string, optional — context key; selected values written as a JSONstring[]),items(Array<{ label: string; value: string }>, required, min 1),defaultValues(string[], optional — must reference valid item values),gap(number),direction("vertical"|"horizontal"), per-item styling (itemBackgroundColor,itemSelectedBackgroundColor,itemBorderColor,itemSelectedBorderColor,itemBorderRadius,itemBorderWidth,itemColor,itemSelectedColor,itemFontSize,itemFontWeight,itemFontFamily,itemPadding,itemPaddingHorizontal,itemPaddingVertical), plus allBaseBoxProps. Validated byCheckboxGroupElementPropsSchema(Zod); includessuperRefinechecks for unique item values and validdefaultValuesentries (per-index error paths).
Backend note: The
onboarding-studioserver must be updated to accept and emit theCheckboxGroupUIElementvariant inComposableScreenpayloads. MirrorCheckboxGroupElementPropsSchemain the backend validation layer and addCheckboxGroupto the CMS element-type picker.
[1.8.1] - 2026-04-22
Added
alignSelfprop onBaseBoxProps— available on all elements that extendBaseBoxProps(Input,RadioGroup,Image,Lottie,Rive,Icon,Video). Accepts"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline".
Changed
alignSelfonStackElement—StackElementPropsandStackElementPropsSchemanow includealignSelf(same enum) in addition to the existingalignItems.
[1.8.0] - 2026-04-21
Added
ButtonUIElement schema forComposableScreen— new discriminated-union variant withtype: "Button". Props:label(string, required, non-empty),action("continue", optional — defaults to callingonContinue),variant("filled"|"outlined"|"ghost"),backgroundColor,color,fontSize,fontWeight,fontFamily,textAlign,alignSelf, plus allBaseBoxProps. Validated byButtonElementPropsSchema(Zod).RadioGroupUIElement schema forComposableScreen— new discriminated-union variant withtype: "RadioGroup". Renders a group of radio options from an inlineitems: Array<{ label: string; value: string }>array. Props:variableName(string, optional — context key),defaultValue,gap,direction("vertical"|"horizontal"), allBaseBoxProps, and per-item styling (itemBackgroundColor,itemSelectedBackgroundColor,itemBorderColor,itemSelectedBorderColor,itemBorderRadius,itemBorderWidth,itemColor,itemSelectedColor,itemFontSize,itemFontWeight,itemFontFamily,itemPadding,itemPaddingHorizontal,itemPaddingVertical). Validated byRadioGroupElementPropsSchema(Zod).- Structured variable entries —
ComposableVariableEntrytype introduced:{ value: string; label?: string }. ThecomposableVariablescontext map is nowRecord<string, ComposableVariableEntry>instead ofRecord<string, string>.RadioGroupwrites bothvalue(raw) andlabel(human-readable) when an item is selected. Expression interpolation inTextelements resolveslabelfirst, falling back tovalue.
Note on semver: The
composableVariablestype changed fromRecord<string, string>toRecord<string, ComposableVariableEntry>. This is published as a minor bump (not major) becausecomposableVariablesis an internal context value not part of the public API contract. Existing consumers remain unaffected — access.valueon the entry for the same string result.
Changed (internal)
ComposableScreenelement types and Zod schemas split intoelements/subfolder — one file per element type.types.tsnow assembles theUIElementunion andUIElementSchemaby importing individual schemas.
Backend note: The
onboarding-studioserver must be updated to accept and emit theRadioGroupUIElementvariant inComposableScreenpayloads. MirrorRadioGroupElementPropsSchemain the backend validation layer and addRadioGroupto the CMS element-type picker.
[1.7.0] - 2026-04-21
Added
fontFamilyprop onTextUIElement — optionalfontFamily?: stringadded to theTextvariant ofUIElementand toTextElementPropsSchema(Zod). Pass any font family name loaded viaexpo-font(or a system font) to apply a custom typeface to a text node.
Backend note: The
onboarding-studioserver should be updated to accept and emitfontFamilyonTextUIElement props, and to expose a font-family input in the CMS text-element editor.
[1.6.0] - 2026-04-21
Added
InputUIElement schema forComposableScreen— new discriminated-union variant withtype: "Input". Renders a<TextInput>that writes its value into shared context viavariableName. Props:variableName(string, optional — context key),placeholder,placeholderColor,defaultValue,keyboardType,returnKeyType,autoCapitalize,secureTextEntry,maxLength,multiline,numberOfLines,editable, plus typography and layout props (color,fontSize,textAlign,padding*) and allBaseBoxProps(backgroundColor,borderWidth,borderRadius,borderColor,width,height,opacity,margin*). Validated byInputElementPropsSchema(Zod).- Variable context —
OnboardingProgressContextnow holdscomposableVariables: Record<string, string>andsetComposableVariable. Values written byInputelements survive navigation betweenComposableScreensteps. - Expression mode for
Textelements —mode?: "plain" | "expression"prop added toTextElementPropsSchema. When"expression",{{variableName}}patterns incontentare interpolated fromcomposableVariablesat render time. Default ("plain") is unchanged.
Backend note: The
onboarding-studioserver must be updated to accept and emit theInputUIElementvariant inComposableScreenpayloads, and to support themodeprop onTextelements. MirrorInputElementPropsSchemaand the updatedTextElementPropsSchemain the backend validation layer and addInputto the CMS element-type picker.
[1.5.0] - 2026-04-21
Added
IconUIElement schema forComposableScreen— new discriminated-union variant withtype: "Icon". Props:name(string, required — Lucide icon name),size(number),color(string),strokeWidth(number), plus allBaseBoxProps. Validated byIconElementPropsSchema(Zod).VideoUIElement schema forComposableScreen— new discriminated-union variant withtype: "Video". Props:url(string, required),autoPlay(boolean),loop(boolean),muted(boolean),controls(boolean), plus allBaseBoxProps. Validated byVideoElementPropsSchema(Zod).
Backend note: The
onboarding-studioserver must be updated to accept and emitIconandVideoUIElementvariants inComposableScreenpayloads. MirrorIconElementPropsSchemaandVideoElementPropsSchemain the backend validation layer and add both types to the CMS element-type picker.
[1.4.0] - 2026-04-21
Added
LottieUIElement forComposableScreen— renders a Lottie animation from a remote JSON URL vialottie-react-native(optional peer dep). Supportssource(required),autoPlay,loop,speed, and allBaseBoxProps(width,height,opacity,margin*,padding*,border*).RiveUIElement forComposableScreen— renders a Rive animation from a remote.rivURL viarive-react-native(optional peer dep). Supportsurl(required),autoplay,fit,alignment,artboardName,stateMachineName, and allBaseBoxProps.
Changed
BaseBoxPropsrefactor —width,height,opacity,margin*,padding*,borderWidth,borderRadius, andborderColorare now defined once in a sharedBaseBoxPropstype andBaseBoxPropsSchema, then extended byImage,Lottie, andRiveelement schemas. Stack and Text schemas are unchanged.
[1.3.0] - 2026-04-17
Added
ImageUIElement forComposableScreen— renders a remote image via React Native<Image>. Supportsurl(required),width,height,aspectRatio,resizeMode(cover|contain|stretch|center),borderRadius,borderWidth,borderColor,opacity, and all margin / padding shorthand props.aspectRatioprop onImageelements — applied as a size fallback whenheightis omitted; defaults to16/9so images never collapse to zero height.
[1.2.0]
Added
- ComposableScreen (under development) — new step type that defines a
declarative UI element tree (
YStack,XStack,Text) driven entirely from the CMS. TheUIElementtype and its Zod schema now support the following props on stack elements:borderWidth,borderRadius,borderColor,overflow,opacity,margin,marginHorizontal,marginVertical,width,height,minWidth,maxWidth,minHeight,maxHeight. Text elements gainmargin,marginHorizontal,marginVertical,borderWidth,borderRadius,borderColor, andopacity.
Note:
ComposableScreenis under active development. The schema may change before it is considered stable.