diff --git a/content/docs/kmp/guides/3rd-party-analytics.mdx b/content/docs/kmp/guides/3rd-party-analytics.mdx index 1a571073..3ec45be4 100644 --- a/content/docs/kmp/guides/3rd-party-analytics.mdx +++ b/content/docs/kmp/guides/3rd-party-analytics.mdx @@ -35,9 +35,7 @@ Superwall.delegate = AnalyticsDelegate() -On Android, `handleSuperwallEvent` has **no guaranteed thread**. It adds no dispatcher hop, so it runs wherever the SDK tracked the event from. Usually that is a background thread. On iOS it is always main. - -Two consequences: do not touch UI from it without hopping to main yourself, and do not assume it is off the main thread either. Keep the body cheap and non-blocking. See [Platform differences](/kmp/guides/platform-differences#delegate-threading). +`handleSuperwallEvent` is **not guaranteed** to arrive on any particular thread, and that may change between releases. Do not touch UI from it without hopping to main yourself, and do not assume it is off the main thread either. Keep the body cheap and non-blocking. See [Platform differences](/kmp/guides/platform-differences#delegate-threading). diff --git a/content/docs/kmp/guides/handling-deep-links.mdx b/content/docs/kmp/guides/handling-deep-links.mdx index 2f9443c1..080e70d8 100644 --- a/content/docs/kmp/guides/handling-deep-links.mdx +++ b/content/docs/kmp/guides/handling-deep-links.mdx @@ -100,7 +100,7 @@ Every case carries the `code` that was redeemed. -`willRedeemLink` and `didRedeemLink` are analytics hooks: they arrive on a **background thread on Android**. The spinner calls above need a main-thread hop on Android. See [Platform differences](/kmp/guides/platform-differences#delegate-threading). +Like every delegate callback, `willRedeemLink` and `didRedeemLink` are not guaranteed to arrive on the main thread. The spinner calls above need a main-thread hop. See [Platform differences](/kmp/guides/platform-differences#delegate-threading). diff --git a/content/docs/kmp/guides/platform-differences.mdx b/content/docs/kmp/guides/platform-differences.mdx index 5ee9c490..732a1abe 100644 --- a/content/docs/kmp/guides/platform-differences.mdx +++ b/content/docs/kmp/guides/platform-differences.mdx @@ -57,37 +57,29 @@ Setting one of these on the other platform is harmless. It is ignored. This is the difference most likely to cause problems, because it is a runtime behavior rather than a missing method. -`SuperwallDelegate` callbacks are **not** forced onto the main thread. They arrive on whatever thread the native SDK called from, which splits cleanly: - -| Hooks | Android | iOS | -| --- | --- | --- | -| `willPresentPaywall`, `didPresentPaywall`, `willDismissPaywall`, `didDismissPaywall`, `handleCustomPaywallAction`, `paywallWillOpenURL`, `paywallWillOpenDeepLink` | Main | Main | -| `subscriptionStatusDidChange`, `customerInfoDidChange`, `userAttributesDidChange`, `willRedeemLink`, `didRedeemLink` | Background | Main | -| `handleSuperwallEvent`, `handleLog` | **Not guaranteed** | Main | - -So the paywall lifecycle hooks are safe for UI work everywhere. The rest are not, on Android. - -`handleSuperwallEvent` and `handleLog` have their own row because they are the least predictable. Neither adds a dispatcher hop on Android, so they run on whatever thread the SDK called from. `handleLog` is invoked inline wherever a log statement executes, which includes the main thread. `handleSuperwallEvent` inherits the context of the code that tracked the event. Usually that is a background thread, but do not rely on it in either direction: do not assume it is safe for UI, and do not assume it is off the main thread. +`SuperwallDelegate` callbacks are **not** guaranteed to arrive on any particular thread. Which thread a given callback lands on depends on the callback and the platform, and it may change between releases, so do not build on the current behavior. - +Treat every delegate callback as if it could arrive on a background thread: -```kotlin -override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { - // Fine: forwarding to an analytics SDK. - analytics.track(eventInfo.eventType.name) +- Do not touch UI from a callback directly. Hop to the main thread yourself. +- Keep implementations thread-safe. Do not assume callbacks are serialized against each other. +- Keep them short. A callback runs before the SDK continues, so blocking in one blocks the SDK, and on whatever thread that happens to be. - // NOT fine on Android: this is a background thread. - // updateMyUi() +```kotlin +class MyDelegate(private val scope: CoroutineScope) : SuperwallDelegate { + override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { + // Forwarding to an analytics SDK is fine from any thread. + analytics.track(eventInfo.eventType.name) + + // Anything that touches UI needs to be dispatched to main. + scope.launch(Dispatchers.Main) { updateMyUi() } + } } ``` -If you need UI from one of those, hop yourself, or collect a flow instead: - -```kotlin -scope.launch(Dispatchers.Main) { updateMyUi() } -``` + Collecting `Superwall.subscriptionStatusFlow` is usually the easier path when you want subscription @@ -96,8 +88,6 @@ scope.launch(Dispatchers.Main) { updateMyUi() } `lifecycleScope`) and you are safe; collect it on `Dispatchers.IO` and you are not. -Two more things to plan for: delegate implementations should be **thread-safe** (the analytics hooks are not serialized against each other), and they run **synchronously on an SDK thread**, so blocking in one slows the SDK. Keep them short. - `PaywallPresentationHandler` closures and the `register` `feature` closure are a different story: those *are* delivered on the main thread on both platforms, deliberately, because they gate UI. diff --git a/content/docs/kmp/guides/using-superwall-delegate.mdx b/content/docs/kmp/guides/using-superwall-delegate.mdx index 37171f26..ac8827b7 100644 --- a/content/docs/kmp/guides/using-superwall-delegate.mdx +++ b/content/docs/kmp/guides/using-superwall-delegate.mdx @@ -93,25 +93,24 @@ override fun didRedeemLink(result: RedemptionResult) {} -Delegate callbacks are **not** forced onto the main thread. They arrive on whatever thread the native SDK called from, and that is not the same on both platforms. +Delegate callbacks are **not** guaranteed to arrive on any particular thread. Which thread a given callback lands on depends on the callback and the platform, and it may change between releases. -- **Paywall lifecycle hooks** arrive on the **main thread** on Android and iOS. Update UI from these directly. -- **State-change hooks** (`subscriptionStatusDidChange`, `customerInfoDidChange`, `userAttributesDidChange`, `willRedeemLink`, `didRedeemLink`) arrive on a **background thread on Android**, and on the main thread on iOS. -- **`handleSuperwallEvent` and `handleLog`** are **not guaranteed** either way on Android. Neither adds a dispatcher hop, so they run on whatever thread the SDK called from. `handleLog` is invoked inline wherever a log statement executes, which includes the main thread. On iOS both are on main. +Treat every callback as if it could arrive on a background thread: - - -Two things this asks of your implementation: +1. **Do not touch UI directly.** Hop to the main thread yourself. +2. **Be thread-safe.** Do not assume callbacks are serialized against each other. +3. **Be quick.** A callback runs before the SDK continues, so blocking in one blocks the SDK. -1. **Be thread-safe.** The analytics hooks are not serialized against each other. -2. **Be quick.** They run synchronously on an SDK thread, so blocking in one slows the SDK. + -If you need UI work from an analytics hook, hop yourself: +If you need UI work from a callback, hop yourself. Give the delegate a scope to launch on: ```kotlin -override fun subscriptionStatusDidChange(from: SubscriptionStatus, to: SubscriptionStatus) { - scope.launch(Dispatchers.Main) { - updateUi(to) +class MyDelegate(private val scope: CoroutineScope) : SuperwallDelegate { + override fun subscriptionStatusDidChange(from: SubscriptionStatus, to: SubscriptionStatus) { + scope.launch(Dispatchers.Main) { + updateUi(to) + } } } ``` diff --git a/content/docs/web/quickstart/configure.mdx b/content/docs/web/quickstart/configure.mdx index bcf8e86a..56c6f38d 100644 --- a/content/docs/web/quickstart/configure.mdx +++ b/content/docs/web/quickstart/configure.mdx @@ -13,21 +13,52 @@ The Web SDK is in beta and its API may change between releases. ## Create an instance -`createSuperwall` returns an instance synchronously and starts configuring in the background. +In React, wrap your app in `SuperwallProvider` once. In plain JavaScript, call `createSuperwall`. Both return an instance synchronously and start configuring in the background. -```ts + + +```tsx React +import { SuperwallProvider } from "@superwall/paywalls-react"; + +export function Root() { + return ( + + + + ); +} +``` + +```ts JavaScript import { createSuperwall } from "@superwall/paywalls-js"; const sw = createSuperwall({ apiKey: "pk_…" }); ``` -Do this once, as early as your app boots. Keep the returned instance around, or use the [named exports](#named-exports) and let the SDK hold it for you. + + +Do this once, as early as your app boots. In React the provider holds the instance for you; reach it from any component with `useSuperwall()`. In plain JavaScript, keep the returned instance around, or use the [named exports](#named-exports) and let the SDK hold it for you. ## Wait for `ready` before presenting `register` waits for identity to hydrate, but it does not wait for configuration. Called before config lands, it returns `{ type: "error" }` carrying a `PaywallNotAvailableError`. -```ts + + +```tsx React +import { use } from "react"; +import { useSuperwall, usePlacement } from "@superwall/paywalls-react"; + +function Checkout() { + const sw = useSuperwall(); + use(sw.ready); // suspends until configured + + const { register } = usePlacement(); + return ; +} +``` + +```ts JavaScript const sw = createSuperwall({ apiKey: "pk_…" }); await sw.ready; @@ -35,6 +66,10 @@ await sw.ready; const result = await sw.register({ placement: "checkout" }); ``` + + +In React, `use(sw.ready)` suspends the component, so put a `` boundary above it. [Gating render on configuration](/web/react/provider#gating-render-on-configuration) covers the boundary and its error handling. + Reads never block. `sw.user.id.value` and `sw.subscriptionStatus.value` return synchronously at any time. Before hydration lands they return defaults (`""` and `{ status: "UNKNOWN" }`). Persisted state replaces the defaults shortly after. @@ -46,7 +81,24 @@ Reads never block. `sw.user.id.value` and `sw.subscriptionStatus.value` return s **Check `configurationStatus`, not `ready`, to detect failure.** A failed config fetch is swallowed internally: `sw.ready` still resolves, and `sw.configurationStatus` becomes `"failed"`. -```ts + + +```tsx React +import { useSuperwall, useSignal } from "@superwall/paywalls-react"; + +function ConfigGuard({ children }: { children: React.ReactNode }) { + const sw = useSuperwall(); + const status = useSignal(sw.configurationStatus); + + if (status === "failed") { + // Superwall could not configure. Paywalls will not present. + return null; + } + return children; +} +``` + +```ts JavaScript await sw.ready; if (sw.configurationStatus.value === "failed") { @@ -54,11 +106,37 @@ if (sw.configurationStatus.value === "failed") { } ``` + + ## Options -```ts +`SuperwallProvider` accepts every option `createSuperwall` does, as props. The two are the same configuration surface. + + + +```tsx React + "…", + }} +> + + +``` + +```ts JavaScript const sw = createSuperwall({ apiKey: "pk_…", options: { @@ -76,6 +154,8 @@ const sw = createSuperwall({ }); ``` + + | Option | Purpose | | --- | --- | | `apiKey` | Your `pk_…` publishable key. Required. | @@ -91,6 +171,12 @@ const sw = createSuperwall({ fingerprinting. + + In React, the provider reads these props once, on first mount for a given `apiKey`. Changing them + afterwards does not reconfigure the SDK. See + [Configuration is read once](/web/react/provider#configuration-is-read-once). + + ## Named exports Instead of threading the instance through your app, import the namespaces directly. The first `createSuperwall` call registers the default instance, and these bind to it. @@ -106,6 +192,8 @@ const result = await register({ placement: "checkout" }); Importing only `user` lets bundlers drop the rest as dead code. +React apps do not need this. `SuperwallProvider` already holds the instance, and the [hooks](/web/react/hooks) reach it through context. + ## Multiple instances Creating more than one instance is supported. Useful for tests, Storybook, and multi-tenant edge workers. The default instance is the first one created in the process, and it is the one the named exports target.