diff --git a/.changeset/fresh-bags-yell.md b/.changeset/fresh-bags-yell.md new file mode 100644 index 00000000..2787830c --- /dev/null +++ b/.changeset/fresh-bags-yell.md @@ -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. diff --git a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt index 5884ee44..a69651d7 100644 --- a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt +++ b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt @@ -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() diff --git a/ios/SuperwallExpoModule.swift b/ios/SuperwallExpoModule.swift index d001a875..c110139f 100644 --- a/ios/SuperwallExpoModule.swift +++ b/ios/SuperwallExpoModule.swift @@ -137,6 +137,14 @@ public class SuperwallExpoModule: Module { ) } + AsyncFunction("refreshConfiguration") { (promise: Promise) in + DispatchQueue.main.async { + Superwall.shared.refreshConfiguration { + promise.resolve(nil) + } + } + } + AsyncFunction("getConfigurationStatus") { (promise: Promise) in let configurationStatus = Superwall.shared.configurationStatus.toString() promise.resolve(configurationStatus) diff --git a/src/SuperwallExpoModule.ts b/src/SuperwallExpoModule.ts index 2df2816f..e472ed2d 100644 --- a/src/SuperwallExpoModule.ts +++ b/src/SuperwallExpoModule.ts @@ -21,6 +21,7 @@ declare class SuperwallExpoModule extends NativeModule + refreshConfiguration(): Promise getConfigurationStatus(): Promise @@ -49,7 +50,11 @@ declare class SuperwallExpoModule extends NativeModule): void didHandleBackPressed(shouldConsume: boolean): void - didHandleCustomCallback(callbackId: string, status: string, data?: Record): Promise + didHandleCustomCallback( + callbackId: string, + status: string, + data?: Record, + ): Promise dismiss(): Promise confirmAllAssignments(): Promise diff --git a/src/SuperwallProvider.tsx b/src/SuperwallProvider.tsx index 68ffcf07..ee5c0065 100644 --- a/src/SuperwallProvider.tsx +++ b/src/SuperwallProvider.tsx @@ -79,6 +79,11 @@ export function SuperwallProvider({ onConfigurationError, }: SuperwallProviderProps) { const deepLinkEventHandlerRef = useRef(null) + const hasObservedConfiguredRef = useRef(false) + const refreshStateRef = useRef({ + isLoading: false, + configurationError: null as string | null, + }) const isUsingCustomPurchaseController = !!useCustomPurchaseController() // Handle onBackPressed callback from options @@ -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] @@ -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]) + // Notify callback when configuration error changes useEffect(() => { if (configurationError && onConfigurationError) { diff --git a/src/__tests__/sdk.behavior.test.tsx b/src/__tests__/sdk.behavior.test.tsx index 8badfefa..741b9229 100644 --- a/src/__tests__/sdk.behavior.test.tsx +++ b/src/__tests__/sdk.behavior.test.tsx @@ -1,8 +1,8 @@ -import React from "react" import TestRenderer, { act } from "react-test-renderer" const mockListeners = new Map 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( @@ -25,7 +25,9 @@ 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", () => ({ @@ -33,6 +35,7 @@ jest.mock("../SuperwallExpoModule", () => ({ default: { addListener: mockAddListener, handleDeepLink: mockHandleDeepLink, + refreshConfiguration: mockRefreshConfiguration, didHandleBackPressed: mockDidHandleBackPressed, didHandleCustomCallback: mockDidHandleCustomCallback, }, @@ -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") @@ -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({ @@ -283,8 +290,11 @@ describe("SDK behavior regressions", () => { let renderer: TestRenderer.ReactTestRenderer await act(async () => { renderer = TestRenderer.create( - - <> + + {null} , ) @@ -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( + {null}, + ) + + 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( + {null}, + ) + + 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( + {null}, + ) + + 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( + {null}, + ) + + 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( + {null}, + ) + + 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() @@ -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(() => {