From 60a19679abd223ad7a9ad86ed2c28dfe009a04cc Mon Sep 17 00:00:00 2001 From: funCapital Date: Sat, 7 Feb 2026 20:23:15 +0300 Subject: [PATCH 1/4] feat: add setLocaleIdentifier for runtime locale changes Expose Superwall.shared.localeIdentifier as a runtime-settable property across iOS, Android, TypeScript module, Zustand store, and compat layer. This allows changing the paywall language at runtime without needing to reconfigure the SDK. Previously, localeIdentifier could only be set via SuperwallOptions at configure time, making dynamic language switching impossible. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit f330f57a4214d8f67813a9844f5a102fb297e915) --- .../modules/superwallexpo/SuperwallExpoModule.kt | 4 ++++ ios/SuperwallExpoModule.swift | 4 ++++ src/SuperwallExpoModule.ts | 1 + src/compat/index.ts | 11 +++++++++++ src/useSuperwall.ts | 14 ++++++++++++++ 5 files changed, 34 insertions(+) diff --git a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt index 42a9d88..e7780a2 100644 --- a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt +++ b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt @@ -572,6 +572,10 @@ class SuperwallExpoModule : Module() { } } + Function("setLocaleIdentifier") { localeIdentifier: String? -> + Superwall.instance.localeIdentifier = localeIdentifier + } + AsyncFunction("setIntegrationAttributes") { attributes: Map, promise: Promise -> scope.launch { try { diff --git a/ios/SuperwallExpoModule.swift b/ios/SuperwallExpoModule.swift index 76b25f8..650117d 100644 --- a/ios/SuperwallExpoModule.swift +++ b/ios/SuperwallExpoModule.swift @@ -448,6 +448,10 @@ public class SuperwallExpoModule: Module { } } + Function("setLocaleIdentifier") { (localeIdentifier: String?) in + Superwall.shared.localeIdentifier = localeIdentifier + } + AsyncFunction("setIntegrationAttributes") { (attributes: [String: String], promise: Promise) in var converted: [IntegrationAttribute: String] = [:] diff --git a/src/SuperwallExpoModule.ts b/src/SuperwallExpoModule.ts index f7edb0f..a91f40a 100644 --- a/src/SuperwallExpoModule.ts +++ b/src/SuperwallExpoModule.ts @@ -78,6 +78,7 @@ declare class SuperwallExpoModule extends NativeModule diff --git a/src/compat/index.ts b/src/compat/index.ts index 2bf5cf5..d0deb3a 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -846,6 +846,17 @@ export default class Superwall { await SuperwallExpoModule.setEventTrackingBehavior(behavior.toString()) } + /** + * Sets the locale identifier for the Superwall SDK. + * This determines the language used when presenting paywalls. + * Can be changed at runtime without needing to reconfigure. + * + * @param localeIdentifier - The locale identifier (e.g., "en_US", "es_ES"), or `null` to reset to the device locale. + */ + async setLocaleIdentifier(localeIdentifier: string | null): Promise { + SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + } + /** * Sets attributes for third-party integrations. * @param attributes - Object mapping IntegrationAttribute string values to their IDs diff --git a/src/useSuperwall.ts b/src/useSuperwall.ts index 1420b23..d2b2780 100644 --- a/src/useSuperwall.ts +++ b/src/useSuperwall.ts @@ -297,6 +297,16 @@ export interface SuperwallStore { */ setLogLevel: (level: string) => Promise + /** + * Sets the locale identifier for the Superwall SDK. + * This determines the language used when presenting paywalls. + * Can be changed at runtime without needing to reconfigure. + * @param localeIdentifier - The locale identifier (e.g., "en", "es", "fr"), or `null` to reset to the device locale. + * @returns A promise that resolves when the locale identifier is set. + */ + setLocaleIdentifier: (localeIdentifier: string | null) => Promise + + /** * Sets which events Superwall tracks at runtime, for GDPR/data-collection control. * @param behavior - The desired event tracking behavior ("all", "superwallOnly", or "none"). @@ -526,6 +536,10 @@ export const useSuperwallStore = create((set, get) => ({ await SuperwallExpoModule.setEventTrackingBehavior(behavior) }, + setLocaleIdentifier: async (localeIdentifier) => { + SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + }, + setIntegrationAttributes: async (attributes) => { await awaitConfigured() await SuperwallExpoModule.setIntegrationAttributes(attributes) From 0e564f193b7dd753155b75a4f70b0ad9e11948f6 Mon Sep 17 00:00:00 2001 From: Xget7 Date: Mon, 14 Sep 2026 07:00:02 -0300 Subject: [PATCH 2/4] test: complete runtime locale configuration handling and coverage --- .changeset/runtime-locale-identifier.md | 5 +++ README.md | 15 +++++++ src/__tests__/compat.locale.test.ts | 52 +++++++++++++++++++++++++ src/__tests__/sdk.behavior.test.tsx | 46 ++++++++++++++++++++++ src/compat/index.ts | 3 +- src/useSuperwall.ts | 6 +-- 6 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 .changeset/runtime-locale-identifier.md create mode 100644 src/__tests__/compat.locale.test.ts diff --git a/.changeset/runtime-locale-identifier.md b/.changeset/runtime-locale-identifier.md new file mode 100644 index 0000000..e3dcbaf --- /dev/null +++ b/.changeset/runtime-locale-identifier.md @@ -0,0 +1,5 @@ +--- +"expo-superwall": minor +--- + +Expose `setLocaleIdentifier(string | null)` in the hooks and compat APIs to change the native locale override after configuration, or restore the device locale with `null`. diff --git a/README.md b/README.md index d40fd53..f88fbcb 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,21 @@ function MyAdvancedComponent() { } ``` +### Changing the paywall locale + +Use `setLocaleIdentifier` after configuration to change the locale used for audience filters and localized paywalls without reconfiguring Superwall. +Pass `null` to return to the device locale. +This updates the native SDK's locale override; it does not translate paywall content or force an already presented paywall to reload. + +```tsx +const setLocaleIdentifier = useSuperwall((state) => state.setLocaleIdentifier); + +await setLocaleIdentifier("es_ES"); +await setLocaleIdentifier(null); +``` + +The compat API exposes the same operation through `Superwall.shared.setLocaleIdentifier(localeIdentifier)`. + ### `useUser` **Purpose:** diff --git a/src/__tests__/compat.locale.test.ts b/src/__tests__/compat.locale.test.ts new file mode 100644 index 0000000..1cd4fdd --- /dev/null +++ b/src/__tests__/compat.locale.test.ts @@ -0,0 +1,52 @@ +const mockConfigure = jest.fn().mockResolvedValue(undefined) +const mockSetLocaleIdentifier = jest.fn() + +// The compat configuration gate only needs an in-process event emitter. +jest.mock("expo", () => ({ EventEmitter: require("node:events").EventEmitter })) + +jest.mock("../SuperwallExpoModule", () => ({ + __esModule: true, + default: { + addListener: jest.fn(() => ({ remove: jest.fn() })), + configure: mockConfigure, + setLocaleIdentifier: mockSetLocaleIdentifier, + }, +})) + +const { default: Superwall }: typeof import("../compat") = require("../compat") + +describe("compat runtime locale", () => { + it("waits for configure, changes the locale, and resets without reconfiguring", async () => { + let resolveConfigure: (() => void) | undefined + mockConfigure.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfigure = resolve + }), + ) + + const pendingConfigure = Superwall.configure({ apiKey: "api-key" }) + const pendingLocale = Superwall.shared.setLocaleIdentifier("es_ES") + await Promise.resolve() + expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() + + resolveConfigure?.() + await pendingConfigure + await pendingLocale + expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES") + + await Superwall.shared.setLocaleIdentifier(null) + expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]]) + expect(mockConfigure).toHaveBeenCalledTimes(1) + }) + + it("rejects when the native setter throws", async () => { + await Superwall.configure({ apiKey: "api-key" }) + mockSetLocaleIdentifier.mockImplementationOnce(() => { + throw new Error("Native locale failure") + }) + + await expect(Superwall.shared.setLocaleIdentifier("fr")).rejects.toThrow( + "Native locale failure", + ) + }) +}) diff --git a/src/__tests__/sdk.behavior.test.tsx b/src/__tests__/sdk.behavior.test.tsx index e36b799..9b052e5 100644 --- a/src/__tests__/sdk.behavior.test.tsx +++ b/src/__tests__/sdk.behavior.test.tsx @@ -17,6 +17,7 @@ const mockGetCustomerInfo = jest.fn().mockResolvedValue({ const mockSetSubscriptionStatus = jest.fn().mockResolvedValue(undefined) const mockSetIntegrationAttributes = jest.fn().mockResolvedValue(undefined) const mockTogglePaywallSpinner = jest.fn() +const mockSetLocaleIdentifier = jest.fn() const mockAddListener = jest.fn( (eventName: string, listener: (payload: any) => void): { remove: () => void } => { const listeners = mockListeners.get(eventName) ?? new Set() @@ -65,6 +66,7 @@ jest.mock("../SuperwallExpoModule", () => ({ didHandleBackPressed: mockDidHandleBackPressed, didHandleCustomCallback: mockDidHandleCustomCallback, togglePaywallSpinner: mockTogglePaywallSpinner, + setLocaleIdentifier: mockSetLocaleIdentifier, }, })) @@ -321,6 +323,50 @@ describe("SDK behavior regressions", () => { expect(mockSetSubscriptionStatus).toHaveBeenCalledWith({ status: "INACTIVE" }) }) + it("waits for configure before changing the locale and can reset to the device locale", async () => { + let resolveConfigure: ((value: boolean) => void) | undefined + mockConfigure.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfigure = resolve + }), + ) + + const pendingLocale = useSuperwallStore.getState().setLocaleIdentifier("es_ES") + const pendingConfigure = useSuperwallStore.getState().configure("api-key") + await Promise.resolve() + + expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() + + resolveConfigure?.(true) + await pendingConfigure + await pendingLocale + expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES") + + await useSuperwallStore.getState().setLocaleIdentifier(null) + expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]]) + expect(mockConfigure).toHaveBeenCalledTimes(1) + }) + + it("does not change the locale when configuration has failed", async () => { + useSuperwallStore.setState({ configurationError: "Configuration failed" }) + + await expect(useSuperwallStore.getState().setLocaleIdentifier("fr")).rejects.toThrow( + "Configuration failed", + ) + expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() + }) + + it("rejects locale changes when the native setter throws", async () => { + useSuperwallStore.setState({ isConfigured: true }) + mockSetLocaleIdentifier.mockImplementationOnce(() => { + throw new Error("Native locale failure") + }) + + await expect(useSuperwallStore.getState().setLocaleIdentifier("fr")).rejects.toThrow( + "Native locale failure", + ) + }) + it("waits for configure before setting integration attributes", async () => { let resolveConfigure: ((value: boolean) => void) | undefined mockConfigure.mockReturnValueOnce( diff --git a/src/compat/index.ts b/src/compat/index.ts index d0deb3a..495c943 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -854,7 +854,8 @@ export default class Superwall { * @param localeIdentifier - The locale identifier (e.g., "en_US", "es_ES"), or `null` to reset to the device locale. */ async setLocaleIdentifier(localeIdentifier: string | null): Promise { - SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + await this.awaitConfig() + await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) } /** diff --git a/src/useSuperwall.ts b/src/useSuperwall.ts index d2b2780..e28301d 100644 --- a/src/useSuperwall.ts +++ b/src/useSuperwall.ts @@ -306,7 +306,6 @@ export interface SuperwallStore { */ setLocaleIdentifier: (localeIdentifier: string | null) => Promise - /** * Sets which events Superwall tracks at runtime, for GDPR/data-collection control. * @param behavior - The desired event tracking behavior ("all", "superwallOnly", or "none"). @@ -448,7 +447,7 @@ export const useSuperwallStore = create((set, get) => ({ identify: async (userId, options) => { await awaitConfigured() - // The previous identity's purchases must not leak into the new one. + // The previous identity's purchases must not leak into the new one. set({ customerInfo: null }) await SuperwallExpoModule.identify(userId, options) @@ -537,7 +536,8 @@ export const useSuperwallStore = create((set, get) => ({ }, setLocaleIdentifier: async (localeIdentifier) => { - SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + await awaitConfigured() + await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) }, setIntegrationAttributes: async (attributes) => { From 3de9ec5fa596d6c2f0bb462e30e1d8d24b0a17d1 Mon Sep 17 00:00:00 2001 From: Xget7 Date: Mon, 14 Sep 2026 07:56:17 -0300 Subject: [PATCH 3/4] docs: remove unrelated README expansion --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index f88fbcb..d40fd53 100644 --- a/README.md +++ b/README.md @@ -373,21 +373,6 @@ function MyAdvancedComponent() { } ``` -### Changing the paywall locale - -Use `setLocaleIdentifier` after configuration to change the locale used for audience filters and localized paywalls without reconfiguring Superwall. -Pass `null` to return to the device locale. -This updates the native SDK's locale override; it does not translate paywall content or force an already presented paywall to reload. - -```tsx -const setLocaleIdentifier = useSuperwall((state) => state.setLocaleIdentifier); - -await setLocaleIdentifier("es_ES"); -await setLocaleIdentifier(null); -``` - -The compat API exposes the same operation through `Superwall.shared.setLocaleIdentifier(localeIdentifier)`. - ### `useUser` **Purpose:** From 66dbb32351d8227fb9196db16f62e489fff8c666 Mon Sep 17 00:00:00 2001 From: Xget7 Date: Mon, 14 Sep 2026 17:40:14 -0300 Subject: [PATCH 4/4] test: keep runtime locale coverage lean --- src/__tests__/compat.locale.test.ts | 11 ----------- src/__tests__/sdk.behavior.test.tsx | 20 -------------------- 2 files changed, 31 deletions(-) diff --git a/src/__tests__/compat.locale.test.ts b/src/__tests__/compat.locale.test.ts index 1cd4fdd..bcfba4a 100644 --- a/src/__tests__/compat.locale.test.ts +++ b/src/__tests__/compat.locale.test.ts @@ -38,15 +38,4 @@ describe("compat runtime locale", () => { expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]]) expect(mockConfigure).toHaveBeenCalledTimes(1) }) - - it("rejects when the native setter throws", async () => { - await Superwall.configure({ apiKey: "api-key" }) - mockSetLocaleIdentifier.mockImplementationOnce(() => { - throw new Error("Native locale failure") - }) - - await expect(Superwall.shared.setLocaleIdentifier("fr")).rejects.toThrow( - "Native locale failure", - ) - }) }) diff --git a/src/__tests__/sdk.behavior.test.tsx b/src/__tests__/sdk.behavior.test.tsx index 9b052e5..f18f908 100644 --- a/src/__tests__/sdk.behavior.test.tsx +++ b/src/__tests__/sdk.behavior.test.tsx @@ -347,26 +347,6 @@ describe("SDK behavior regressions", () => { expect(mockConfigure).toHaveBeenCalledTimes(1) }) - it("does not change the locale when configuration has failed", async () => { - useSuperwallStore.setState({ configurationError: "Configuration failed" }) - - await expect(useSuperwallStore.getState().setLocaleIdentifier("fr")).rejects.toThrow( - "Configuration failed", - ) - expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() - }) - - it("rejects locale changes when the native setter throws", async () => { - useSuperwallStore.setState({ isConfigured: true }) - mockSetLocaleIdentifier.mockImplementationOnce(() => { - throw new Error("Native locale failure") - }) - - await expect(useSuperwallStore.getState().setLocaleIdentifier("fr")).rejects.toThrow( - "Native locale failure", - ) - }) - it("waits for configure before setting integration attributes", async () => { let resolveConfigure: ((value: boolean) => void) | undefined mockConfigure.mockReturnValueOnce(