Skip to content
Open
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: 4 additions & 0 deletions .changeset/fresh-bags-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"expo-superwall": patch
---

Refresh Superwall configuration automatically for the hooks SDK after Metro/Fast Refresh in development, so dashboard config updates can be picked up without a full app restart.
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ class SuperwallExpoModule : Module() {
}
}

AsyncFunction("refreshConfiguration") { promise: Promise ->
try {
Superwall.instance.refreshConfiguration()
promise.resolve(null)
} catch (error: Throwable) {
promise.reject(CodedException(error))
}
}

AsyncFunction("getConfigurationStatus") { promise: Promise ->
try {
val configurationStatus = Superwall.instance.configurationState.asString()
Expand Down
8 changes: 8 additions & 0 deletions ios/SuperwallExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ public class SuperwallExpoModule: Module {
)
}

AsyncFunction("refreshConfiguration") { (promise: Promise) in
DispatchQueue.main.async {
Superwall.shared.refreshConfiguration {
promise.resolve(nil)
}
}
Comment on lines 138 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Promise never rejects on iOS

The completion closure only ever calls promise.resolve(nil), so any internal failure in Superwall.shared.refreshConfiguration is silently swallowed on iOS. The Android counterpart wraps the call in try/catch and calls promise.reject(CodedException(error)) on failure. While the TypeScript caller currently uses .catch() for Metro-refresh, future callers awaiting refreshConfiguration() would receive a resolved promise even when the SDK failed on iOS. Consider adding error propagation — if the SuperwallKit closure provides an Error? parameter, reject there; otherwise wrap the call in a do/catch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/SuperwallExpoModule.swift
Line: 138-145

Comment:
**Promise never rejects on iOS**

The completion closure only ever calls `promise.resolve(nil)`, so any internal failure in `Superwall.shared.refreshConfiguration` is silently swallowed on iOS. The Android counterpart wraps the call in `try/catch` and calls `promise.reject(CodedException(error))` on failure. While the TypeScript caller currently uses `.catch()` for Metro-refresh, future callers awaiting `refreshConfiguration()` would receive a resolved promise even when the SDK failed on iOS. Consider adding error propagation — if the SuperwallKit closure provides an `Error?` parameter, reject there; otherwise wrap the call in a `do/catch`.

How can I resolve this? If you propose a fix, please make it concise.

}

AsyncFunction("getConfigurationStatus") { (promise: Promise) in
let configurationStatus = Superwall.shared.configurationStatus.toString()
promise.resolve(configurationStatus)
Expand Down
7 changes: 6 additions & 1 deletion src/SuperwallExpoModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ declare class SuperwallExpoModule extends NativeModule<SuperwallExpoModuleEvents
usingPurchaseController?: boolean,
sdkVersion?: string,
): Promise<void>
refreshConfiguration(): Promise<void>

getConfigurationStatus(): Promise<string>

Expand Down Expand Up @@ -49,7 +50,11 @@ declare class SuperwallExpoModule extends NativeModule<SuperwallExpoModuleEvents
): void
didRestore(result: Record<string, any>): void
didHandleBackPressed(shouldConsume: boolean): void
didHandleCustomCallback(callbackId: string, status: string, data?: Record<string, any>): Promise<void>
didHandleCustomCallback(
callbackId: string,
status: string,
data?: Record<string, any>,
): Promise<void>

dismiss(): Promise<void>
confirmAllAssignments(): Promise<any[]>
Expand Down
27 changes: 27 additions & 0 deletions src/SuperwallProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export function SuperwallProvider({
onConfigurationError,
}: SuperwallProviderProps) {
const deepLinkEventHandlerRef = useRef<EmitterSubscription>(null)
const hasObservedConfiguredRef = useRef(false)
const refreshStateRef = useRef({
isLoading: false,
configurationError: null as string | null,
})
const isUsingCustomPurchaseController = !!useCustomPurchaseController()

// Handle onBackPressed callback from options
Expand All @@ -95,6 +100,11 @@ export function SuperwallProvider({
})),
)

refreshStateRef.current = {
isLoading,
configurationError,
}

useEffect(() => {
if (!isConfigured && !isLoading && !configurationError) {
const apiKey = apiKeys[Platform.OS as keyof typeof apiKeys]
Expand Down Expand Up @@ -124,6 +134,23 @@ export function SuperwallProvider({
configure,
])

useEffect(() => {
const { isLoading, configurationError } = refreshStateRef.current

if (!__DEV__ || !isConfigured || isLoading || configurationError) {
return
}

if (!hasObservedConfiguredRef.current) {
hasObservedConfiguredRef.current = true
return
}

SuperwallExpoModule.refreshConfiguration().catch((error) => {
console.error("[Superwall] Failed to refresh configuration after Metro refresh", error)
})
}, [isConfigured])
Comment on lines +137 to +152
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refresh can be silently skipped when isLoading lags behind isConfigured

isLoading and configurationError are read from refreshStateRef.current inside an effect that only depends on [isConfigured]. If the store emits isConfigured = true in one render and then clears isLoading in a subsequent render (i.e. the two state updates are not batched), the effect runs while isLoading is still true and exits early — the refresh is never attempted, with no log or retry. This is an edge case, but Metro-refresh timing makes it more likely than typical usage.

A safer alternative is to also depend on isLoading and configurationError directly:

useEffect(() => {
  if (!__DEV__ || !isConfigured || isLoading || configurationError) {
    return
  }

  if (!hasObservedConfiguredRef.current) {
    hasObservedConfiguredRef.current = true
    return
  }

  SuperwallExpoModule.refreshConfiguration().catch((error) => {
    console.error("[Superwall] Failed to refresh configuration after Metro refresh", error)
  })
}, [isConfigured, isLoading, configurationError])

The hasObservedConfiguredRef already prevents spurious calls, so the extra dependencies don't add refresh invocations — they only ensure the effect re-evaluates when isLoading or the error clears after isConfigured is set.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/SuperwallProvider.tsx
Line: 137-152

Comment:
**Refresh can be silently skipped when `isLoading` lags behind `isConfigured`**

`isLoading` and `configurationError` are read from `refreshStateRef.current` inside an effect that only depends on `[isConfigured]`. If the store emits `isConfigured = true` in one render and then clears `isLoading` in a subsequent render (i.e. the two state updates are not batched), the effect runs while `isLoading` is still `true` and exits early — the refresh is never attempted, with no log or retry. This is an edge case, but Metro-refresh timing makes it more likely than typical usage.

A safer alternative is to also depend on `isLoading` and `configurationError` directly:

```ts
useEffect(() => {
  if (!__DEV__ || !isConfigured || isLoading || configurationError) {
    return
  }

  if (!hasObservedConfiguredRef.current) {
    hasObservedConfiguredRef.current = true
    return
  }

  SuperwallExpoModule.refreshConfiguration().catch((error) => {
    console.error("[Superwall] Failed to refresh configuration after Metro refresh", error)
  })
}, [isConfigured, isLoading, configurationError])
```

The `hasObservedConfiguredRef` already prevents spurious calls, so the extra dependencies don't add refresh invocations — they only ensure the effect re-evaluates when `isLoading` or the error clears after `isConfigured` is set.

How can I resolve this? If you propose a fix, please make it concise.


// Notify callback when configuration error changes
useEffect(() => {
if (configurationError && onConfigurationError) {
Expand Down
223 changes: 214 additions & 9 deletions src/__tests__/sdk.behavior.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react"
import TestRenderer, { act } from "react-test-renderer"

const mockListeners = new Map<string, Set<(payload: any) => void>>()
const mockHandleDeepLink = jest.fn().mockResolvedValue(false)
const mockRefreshConfiguration = jest.fn().mockResolvedValue(undefined)
const mockDidHandleBackPressed = jest.fn()
const mockDidHandleCustomCallback = jest.fn().mockResolvedValue(undefined)
const mockAddListener = jest.fn(
Expand All @@ -25,14 +25,17 @@ const mockAddListener = jest.fn(
const emit = (eventName: string, payload: any) => {
const listeners = mockListeners.get(eventName)
if (!listeners) return
listeners.forEach((listener) => listener(payload))
listeners.forEach((listener) => {
listener(payload)
})
}

jest.mock("../SuperwallExpoModule", () => ({
__esModule: true,
default: {
addListener: mockAddListener,
handleDeepLink: mockHandleDeepLink,
refreshConfiguration: mockRefreshConfiguration,
didHandleBackPressed: mockDidHandleBackPressed,
didHandleCustomCallback: mockDidHandleCustomCallback,
},
Expand All @@ -56,7 +59,10 @@ jest.mock("react-native", () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

const { SuperwallProvider }: typeof import("../SuperwallProvider") = require("../SuperwallProvider")
const { SuperwallContext, useSuperwallStore }: typeof import("../useSuperwall") = require("../useSuperwall")
const {
SuperwallContext,
useSuperwallStore,
}: typeof import("../useSuperwall") = require("../useSuperwall")
const { usePlacement }: typeof import("../usePlacement") = require("../usePlacement")
const { useSuperwallEvents }: typeof import("../useSuperwallEvents") =
require("../useSuperwallEvents")
Expand All @@ -71,6 +77,7 @@ describe("SDK behavior regressions", () => {
beforeEach(() => {
jest.clearAllMocks()
__resetSuperwallEventBridgeForTests()
;(globalThis as { __DEV__?: boolean }).__DEV__ = true
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {})
useSuperwallStore.setState({
Expand Down Expand Up @@ -283,8 +290,11 @@ describe("SDK behavior regressions", () => {
let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ android: "android-key" }} onConfigurationError={onConfigurationError}>
<></>
<SuperwallProvider
apiKeys={{ android: "android-key" }}
onConfigurationError={onConfigurationError}
>
{null}
</SuperwallProvider>,
)

Expand All @@ -305,6 +315,204 @@ describe("SDK behavior regressions", () => {
renderer!.unmount()
})
})
it("configures once and does not immediately refresh after the first successful configuration", async () => {
const configure = jest.fn().mockResolvedValue(undefined)

useSuperwallStore.setState({ configure })

let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ ios: "ios-key" }}>{null}</SuperwallProvider>,
)

await Promise.resolve()
})

expect(configure).toHaveBeenCalledTimes(1)
expect(mockRefreshConfiguration).not.toHaveBeenCalled()

act(() => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})
})

expect(mockRefreshConfiguration).not.toHaveBeenCalled()

act(() => {
renderer!.unmount()
})
})

it("refreshes configuration on a later dev rerun once configuration was already observed", async () => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})

let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ ios: "ios-key" }}>{null}</SuperwallProvider>,
)

await Promise.resolve()
})

expect(mockRefreshConfiguration).not.toHaveBeenCalled()

act(() => {
useSuperwallStore.setState({ isConfigured: false })
})

await act(async () => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})
await Promise.resolve()
})

expect(mockRefreshConfiguration).toHaveBeenCalledTimes(1)

act(() => {
renderer!.unmount()
})
})

it("does not refresh configuration outside development", async () => {
;(globalThis as { __DEV__?: boolean }).__DEV__ = false

useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})

let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ ios: "ios-key" }}>{null}</SuperwallProvider>,
)

await Promise.resolve()
})

act(() => {
useSuperwallStore.setState({ isConfigured: false })
})

await act(async () => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})
await Promise.resolve()
})

expect(mockRefreshConfiguration).not.toHaveBeenCalled()

act(() => {
renderer!.unmount()
})
})

it("catches refresh failures locally", async () => {
const refreshError = new Error("refresh failed")
mockRefreshConfiguration.mockRejectedValueOnce(refreshError)

useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})

let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ ios: "ios-key" }}>{null}</SuperwallProvider>,
)

await Promise.resolve()
})

act(() => {
useSuperwallStore.setState({ isConfigured: false })
})

await act(async () => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: null,
})
await Promise.resolve()
})

await act(async () => {
await Promise.resolve()
})

expect(consoleErrorSpy).toHaveBeenCalledWith(
"[Superwall] Failed to refresh configuration after Metro refresh",
refreshError,
)

act(() => {
renderer!.unmount()
})
})

it("updates configuration error state from config refresh and fail events", async () => {
useSuperwallStore.setState({
isConfigured: true,
isLoading: false,
configurationError: "stale error",
})

let renderer: TestRenderer.ReactTestRenderer
await act(async () => {
renderer = TestRenderer.create(
<SuperwallProvider apiKeys={{ ios: "ios-key" }}>{null}</SuperwallProvider>,
)

await Promise.resolve()
})

act(() => {
emit("handleSuperwallEvent", {
eventInfo: {
event: { event: "configRefresh" },
params: null,
},
})
})

expect(useSuperwallStore.getState().configurationError).toBeNull()

act(() => {
emit("handleSuperwallEvent", {
eventInfo: {
event: { event: "configFail" },
params: null,
},
})
})

expect(useSuperwallStore.getState().configurationError).toBe(
"Failed to load Superwall configuration",
)

act(() => {
renderer!.unmount()
})
})

it("replays buffered log and superwall events once after hooks mount", () => {
const onLog = jest.fn()
Expand Down Expand Up @@ -420,10 +628,7 @@ describe("SDK behavior regressions", () => {
})

expect(matchingDismiss).toHaveBeenCalledTimes(1)
expect(matchingDismiss).toHaveBeenCalledWith(
{ name: "buffered-paywall" },
{ type: "declined" },
)
expect(matchingDismiss).toHaveBeenCalledWith({ name: "buffered-paywall" }, { type: "declined" })
expect(globalDismiss).not.toHaveBeenCalled()

act(() => {
Expand Down
Loading