Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions content/docs/kmp/guides/3rd-party-analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ Superwall.delegate = AnalyticsDelegate()

<Warning>

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).

</Warning>

Expand Down
2 changes: 1 addition & 1 deletion content/docs/kmp/guides/handling-deep-links.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Every case carries the `code` that was redeemed.

<Warning>

`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).

</Warning>

Expand Down
40 changes: 15 additions & 25 deletions content/docs/kmp/guides/platform-differences.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Warning>

`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.

</Warning>
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() }
```
</Warning>

<Note>
Collecting `Superwall.subscriptionStatusFlow` is usually the easier path when you want subscription
Expand All @@ -96,8 +88,6 @@ scope.launch(Dispatchers.Main) { updateMyUi() }
`lifecycleScope`) and you are safe; collect it on `Dispatchers.IO` and you are not.
</Note>

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.

<Note>
`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.
Expand Down
25 changes: 12 additions & 13 deletions content/docs/kmp/guides/using-superwall-delegate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,24 @@ override fun didRedeemLink(result: RedemptionResult) {}

<Warning>

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:

</Warning>

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.
</Warning>

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)
}
}
}
```
Expand Down
100 changes: 94 additions & 6 deletions content/docs/web/quickstart/configure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,63 @@ 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
<CodeGroup>

```tsx React
import { SuperwallProvider } from "@superwall/paywalls-react";

export function Root() {
return (
<SuperwallProvider apiKey="pk_…">
<App />
</SuperwallProvider>
);
}
```

```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.
</CodeGroup>

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
<CodeGroup>

```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 <button onClick={() => register({ placement: "checkout" })}>Upgrade</button>;
}
```

```ts JavaScript
const sw = createSuperwall({ apiKey: "pk_…" });

await sw.ready;

const result = await sw.register({ placement: "checkout" });
```

</CodeGroup>

In React, `use(sw.ready)` suspends the component, so put a `<Suspense>` 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.

<Tip>
Expand All @@ -46,19 +81,62 @@ 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
<CodeGroup>

```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") {
// Superwall could not configure. Paywalls will not present.
}
```

</CodeGroup>

</Warning>

## Options

```ts
`SuperwallProvider` accepts every option `createSuperwall` does, as props. The two are the same configuration surface.

<CodeGroup>

```tsx React
<SuperwallProvider
apiKey="pk_…"
options={{
/* SuperwallOptions: logging, networking, paywall behavior */
}}
delegate={myDelegate}
storage={myStorageAdapter}
purchaseController={myPurchaseController}
identity={{
appUserId: "user_123",
aliasId: "$SuperwallAlias:…",
vendorId: "…",
vendorIdProvider: async () => "…",
}}
>
<App />
</SuperwallProvider>
```

```ts JavaScript
const sw = createSuperwall({
apiKey: "pk_…",
options: {
Expand All @@ -76,6 +154,8 @@ const sw = createSuperwall({
});
```

</CodeGroup>

| Option | Purpose |
| --- | --- |
| `apiKey` | Your `pk_…` publishable key. Required. |
Expand All @@ -91,6 +171,12 @@ const sw = createSuperwall({
fingerprinting.
</Tip>

<Note>
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).
</Note>

## 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.
Expand All @@ -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.
Expand Down
Loading